openvibers/openvibe.chat Commits

Platform blocks (WS-E task 5, Contracts 0.49.0): Chat consumes network.block.changed into a newest-revision projection, and a network block counts like dm_blocks for new DMs, 1:1 messages, group invites, calls and the DM user search, both ways; scripts/migrate-dm-blocks-to-network.js exports dm_blocks as subject pairs for Network's import (dry run by default)

OP OpenVibers committed 25 Sept 2026 · ea1770a04b · 9 files, +355 −16


Changes

ddiff --git a/README.md b/README.md
iindex 8226737..c25615f 100644
---- a/README.md
++++ b/README.md
@@@ -100,13 +100,14 @@ OpenVibe.Events' `scripts/redact-backfill.js`.
#### Consumed
--Chat subscribes (consumer `chat`) to two topics, delivered to `POST /internal/events`
++Chat subscribes (consumer `chat`) to these topics (and `network.user.token_valid_after`, `vip.membership.changed`), delivered to `POST /internal/events`
(`server/events/consumer.js`):
| Event | From | What Chat does |
| --- | --- | --- |
| `live.release.deployed` | Live (`server/events/release-events.js`), subject `{ type: release, id: <head commit> }` | stores or folds the deploy card in global chat, exactly as the bridge op `deployNotice` does (`server/chat/deploy-notice.js`: one rolling card, folded while nobody has spoken in any room and within 3 hours; the broadcast carries the row id; late joiners get it once) |
| `network.module.updated` | Network (`server/identity/module-events.js`) | for `chat.preferences`, a revision newer than the cached copy drops it (`prefs.handleEvent()`); an older or equal revision and other namespaces change nothing |
++| `network.block.changed` | Network (`server/identity/blocks.js`, platform blocks) | keeps the newest revision per (blocker, blocked) in `network_blocks` (`server/chat/network-blocks.js`); while a block is active neither person can start a DM with the other, message them in a 1:1, add them to a group or call them, and the DM user search hides them, exactly like `dm_blocks` (same errors) |
- **One card per deploy, whichever path comes first** (compatibility register C-84). Until the bridge
op is removed, Live sends each deploy twice: `deployNotice` over the bridge (commits newest first,
@@@ -128,6 +129,17 @@ Chat subscribes (consumer `chat`) to two topics, delivered to `POST /internal/ev
`CHAT_EVENTS_SECRET`, `OV_OAUTH_CLIENT_SECRET` and the Network grant
`chat events.subscription.manage openvibe.events`.
++Platform blocks (WS-E task 5): Chat's own `dm_blocks` stay in force beside Network's. To carry them over
++once, export them as subject pairs and import those on Network (`scripts/import-blocks.js` there):
++
++```bash
++node scripts/migrate-dm-blocks-to-network.js [--resolve] # dry run: counts and unmapped Live ids
++node scripts/migrate-dm-blocks-to-network.js --apply --out /root/dm-blocks.json # write the pairs file (0600)
++```
++
++Mentions: Chat produces no mention notifications (the @mention highlight, tab flash and sound are drawn by
++Live's chat client from the broadcast message), so there is nothing in Chat for a block to suppress yet.
++
```bash
sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --dry-run # list them
sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --disable # rollback: no more deliveries
ddiff --git a/package-lock.json b/package-lock.json
iindex 902aa03..46c3484 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
-- "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",
"ws": "^8.18.0"
@@@ -1062,9 +1062,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 6be699e..f40ddb2 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
-- "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",
"ws": "^8.18.0"
ddiff --git a/scripts/migrate-dm-blocks-to-network.js b/scripts/migrate-dm-blocks-to-network.js
nnew file mode 100644
iindex 0000000..62e2ac0
---- /dev/null
++++ b/scripts/migrate-dm-blocks-to-network.js
@@@ -0,0 +1,112 @@
++#!/usr/bin/env node
++/**
++ * Chat's own DM blocks (dm_blocks) → platform blocks on OpenVibe.Network (roadmap WS-E task 5). One-off.
++ *
++ * Network has no service API that writes blocks (a block is the person's own act), so this does not call
++ * Network: it writes the blocks as a JSON file of subject pairs for an operator to import there with
++ * Network's scripts/import-blocks.js, which inserts each pair Network has never seen and emits
++ * network.block.changed for it. Chat keeps dm_blocks as they are (DMs honour both).
++ *
++ * node scripts/migrate-dm-blocks-to-network.js # dry run: counts only, nothing written
++ * node scripts/migrate-dm-blocks-to-network.js --apply --out <file> # write the pairs file (0600, new file)
++ *
++ * Options:
++ * --out <file> required with --apply; must not exist
++ * --resolve first ask Live (live-context, as Chat does at runtime) for people missing from ctx_users
++ * --db <path> Chat's database (default CHAT_DB_PATH, else ./data/chat.db)
++ *
++ * People are Live ids here; each is mapped to its Network subject the way Chat records it: the blocker's
++ * dm_blocks.blocker_subject_id (written when they blocked), else ctx_users.subject_id; the blocked person's
++ * ctx_users.subject_id. A Live account without a Network subject has no platform identity to block with,
++ * so its rows are skipped and counted. Then, on Network (as the service user):
++ *
++ * node scripts/import-blocks.js --file <file> # dry run
++ * node scripts/import-blocks.js --file <file> --apply
++ *
++ * Exit codes: 0 done (or dry run), 2 refused (bad options), 1 failed.
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++
++const USAGE = 'usage: node scripts/migrate-dm-blocks-to-network.js [--db <chat.db>] [--resolve] [--apply --out <file>]';
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function parseArgs(argv) {
++ const out = { apply: false, out: null, resolve: false, db: null, help: false };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ const value = () => { const v = argv[++i]; if (!v || v.startsWith('--')) throw new Error(`${a} needs a value`); return v; };
++ if (a === '--apply') out.apply = true;
++ else if (a === '--dry-run') out.apply = false;
++ else if (a === '--resolve') out.resolve = true;
++ else if (a === '--out') out.out = value();
++ else if (a === '--db') out.db = value();
++ else if (a === '-h' || a === '--help') out.help = true;
++ else throw new Error(`unknown argument ${a}`);
++ }
++ if (out.apply && !out.out) throw new Error('--apply needs --out <file>');
++ if (out.out && fs.existsSync(out.out)) throw new Error(`${out.out} exists already; pick a new file`);
++ return out;
++}
++
++/** dm_blocks → { pairs: [{ blocker_subject, blocked_subject }], rows, skipped: { reason: n }, unmapped_ids } */
++function plan(db) {
++ const rows = db.all(`SELECT b.id, b.blocker_id, b.blocked_id, b.blocker_subject_id, a.subject_id AS blocker_ctx, c.subject_id AS blocked_ctx
++ FROM dm_blocks b LEFT JOIN ctx_users a ON a.id = b.blocker_id LEFT JOIN ctx_users c ON c.id = b.blocked_id ORDER BY b.id`);
++ const ok = (s) => (SUBJECT_RE.test(String(s || '')) ? String(s) : null);
++ const pairs = [];
++ const skipped = {};
++ const unmapped = new Set();
++ const seen = new Set();
++ const skip = (why) => { skipped[why] = (skipped[why] || 0) + 1; };
++ for (const r of rows) {
++ const blocker = ok(r.blocker_subject_id) || ok(r.blocker_ctx);
++ const blocked = ok(r.blocked_ctx);
++ if (!blocker) { skip('blocker-without-subject'); unmapped.add(r.blocker_id); continue; }
++ if (!blocked) { skip('blocked-without-subject'); unmapped.add(r.blocked_id); continue; }
++ if (blocker === blocked) { skip('same-person'); continue; }
++ const key = `${blocker}>${blocked}`;
++ if (seen.has(key)) { skip('duplicate'); continue; }
++ seen.add(key);
++ pairs.push({ blocker_subject: blocker, blocked_subject: blocked });
++ }
++ return { rows: rows.length, pairs, skipped, unmapped_ids: [...unmapped].sort((x, y) => x - y) };
++}
++
++async function main(argv, log = (m) => console.log(m)) {
++ let args;
++ try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
++ if (args.help) { log(USAGE); return 0; }
++ if (args.db) {
++ if (!fs.existsSync(args.db)) { log(`no Chat database at ${args.db}`); return 2; }
++ process.env.CHAT_DB_PATH = path.resolve(args.db);
++ }
++ const db = require('../server/db/database');
++ if (args.resolve) {
++ const missing = db.all(`SELECT DISTINCT id FROM (SELECT blocker_id AS id FROM dm_blocks UNION SELECT blocked_id FROM dm_blocks)
++ WHERE id NOT IN (SELECT id FROM ctx_users)`).map((r) => r.id);
++ if (missing.length) {
++ const ctx = require('../server/live-context');
++ for (let i = 0; i < missing.length; i += 500) await ctx.ensureUsers(missing.slice(i, i + 500));
++ }
++ log(`asked Live for ${missing.length} person(s) missing from ctx_users`);
++ }
++ const p = plan(db);
++ const why = Object.entries(p.skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
++ log(`dm_blocks ${p.rows}; pairs for Network ${p.pairs.length}; skipped: ${why}`);
++ if (p.unmapped_ids.length) log(`Live ids without a Network subject: ${p.unmapped_ids.slice(0, 50).join(', ')}${p.unmapped_ids.length > 50 ? ` (+${p.unmapped_ids.length - 50} more)` : ''}`);
++ if (!args.apply) { log('dry run: nothing written (add --apply --out <file> to write the pairs file)'); return 0; }
++ const body = { exported_at: new Date().toISOString(), source: 'openvibe.chat dm_blocks', pairs: p.pairs, skipped: p.skipped };
++ const umask = process.umask(0o077);
++ try { fs.writeFileSync(args.out, `${JSON.stringify(body, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); } finally { process.umask(umask); }
++ log(`wrote ${p.pairs.length} pair(s) to ${args.out}; import on Network: node scripts/import-blocks.js --file <that file> [--apply]`);
++ return 0;
++}
++
++if (require.main === module) {
++ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`migrate-dm-blocks-to-network failed: ${err.message}`); process.exit(1); });
++}
++
++module.exports = { main, plan, parseArgs };
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
iindex d79252d..949defd 100644
---- a/server/chat/dm.js
++++ b/server/chat/dm.js
@@@ -13,10 +13,12 @@
* People are still Live user ids; names and pictures come from the ctx_users projection
* (server/live-context.js). New participants, messages and blocks also record the Network
* subject, and every message adds a chat.dm.created event (visibility subject) to the outbox in
-- * the same transaction.
++ * the same transaction. Platform blocks (./network-blocks.js) count like dm_blocks everywhere
++ * isBlockedEither is asked: new conversations, group invites, 1:1 messages, calls.
*/
const db = require('../db/database');
const outbox = require('../events/outbox');
++const networkBlocks = require('./network-blocks');
// ── Schema & Migrations ──────────────────────────────────────
@@@ -257,28 +259,33 @@ function getTotalUnread(userId) {
*/
function searchUsers(query, excludeUserId, limit = 10) {
if (!query || query.length < 2) return [];
++ networkBlocks.ensureSchema();
++ const me = db.subjectFor(excludeUserId) || '';
return db.all(`
SELECT id, username, display_name, avatar_url, profile_color
FROM ctx_users
WHERE id != ? AND is_banned = 0
AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_id = ?)
AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?)
++ AND COALESCE(subject_id, '') NOT IN (SELECT blocked_subject FROM network_blocks WHERE blocker_subject = ? AND active = 1)
++ AND COALESCE(subject_id, '') NOT IN (SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1)
AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE)
LIMIT ?
-- `, [excludeUserId, excludeUserId, excludeUserId, `%${query}%`, `%${query}%`, limit]);
++ `, [excludeUserId, excludeUserId, excludeUserId, me, me, `%${query}%`, `%${query}%`, limit]);
}
// ── Block helpers ────────────────────────────────────────────
/**
-- * Check if either user has blocked the other (bidirectional).
++ * Check if either user has blocked the other (bidirectional): in Chat's dm_blocks, or on the network
++ * (platform blocks, ./network-blocks.js, by Network subject).
*/
function isBlockedEither(userIdA, userIdB) {
return !!db.get(
`SELECT 1 FROM dm_blocks
WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,
[userIdA, userIdB, userIdB, userIdA]
-- );
++ ) || networkBlocks.eitherBlockedUsers(userIdA, userIdB);
}
/**
ddiff --git a/server/chat/network-blocks.js b/server/chat/network-blocks.js
nnew file mode 100644
iindex 0000000..49e67c5
---- /dev/null
++++ b/server/chat/network-blocks.js
@@@ -0,0 +1,72 @@
++/**
++ * OpenVibe.Chat — platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed).
++ *
++ * People block each other once, on OpenVibe.Network, and Chat honours it. Network announces every change
++ * as network.block.changed (blocker, blocked, active, a per-pair revision); ../events/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.
++ *
++ * DMs (./dm.js isBlockedEither) refuse a conversation, a group invite or a direct message between two
++ * people when either blocked the other, here or in Chat's own dm_blocks. Blocks are between Network
++ * subjects; Chat's people are Live ids, mapped through ctx_users.subject_id (db.subjectFor).
++ */
++'use strict';
++
++const { validate } = require('openvibe-contracts');
++const db = require('../db/database');
++
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++let ready = false;
++
++function ensureSchema() {
++ if (ready) return;
++ db.getDb().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 = true;
++}
++
++/** The payload of a network.block.changed envelope from Network, or null when it is not one. */
++function payloadOf(event) {
++ if (!event || event.source !== 'network' || event.event_type !== 'network.block.changed') return null;
++ const p = event.payload;
++ if (!p || typeof p !== 'object' || !validate('network.block.changed@1', p).valid) return null;
++ return p;
++}
++
++/** Apply one change when it is newer than what is kept for its pair. → true when the projection moved. */
++function apply(p, now = Date.now()) {
++ ensureSchema();
++ const r = db.run(`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`, [p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now]);
++ return r.changes > 0;
++}
++
++/** Did `blocker` block `blocked` (subjects)? */
++function hasBlocked(blocker, blocked) {
++ if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || ''))) return false;
++ ensureSchema();
++ return !!db.get('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1', [blocker, blocked]);
++}
++
++/** Did either of two subjects block the other? */
++function eitherBlocked(a, b) {
++ return hasBlocked(a, b) || hasBlocked(b, a);
++}
++
++/** The same, for two Live user ids (through their Network subjects). */
++function eitherBlockedUsers(userIdA, userIdB) {
++ return eitherBlocked(db.subjectFor(userIdA), db.subjectFor(userIdB));
++}
++
++/** For tests: forget that the table was created (the table stays). */
++function _reset() { ready = false; }
++
++module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, _reset };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 14500f4..97f56ef 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -17,6 +17,9 @@
* vip.membership.changed a membership started, lapsed or was revoked (source vip): the member's cached
* subscriber-badge answers for that creator are dropped at once
* (../vip/badges.js handleEvent) instead of converging by the cache TTL.
++ * network.block.changed someone blocked or unblocked someone on the network (platform blocks, Contracts
++ * 0.49.0). ../chat/network-blocks.js keeps the newest revision per (blocker, blocked);
++ * DMs refuse a conversation, invite or message between them while it is active.
*
* Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite transaction as
* the change, so a redelivery does nothing and a failure rolls both back (Events retries). Broadcasts
@@@ -39,11 +42,12 @@ const deployNotice = require('../chat/deploy-notice');
const prefStores = require('../prefs/stores');
const vipBadges = require('../vip/badges');
const revocations = require('../auth/revocations');
++const networkBlocks = require('../chat/network-blocks');
const ctx = require('../live-context');
const CONSUMER = 'chat';
const INBOX_TABLE = 'chat_event_inbox';
--const TOPICS = Object.freeze(['live.release.deployed', 'network.module.updated', 'network.user.token_valid_after', 'vip.membership.changed']);
++const TOPICS = Object.freeze(['live.release.deployed', 'network.module.updated', 'network.user.token_valid_after', 'vip.membership.changed', 'network.block.changed']);
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 days: nothing older can be redelivered
@@@ -102,6 +106,13 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
};
};
}
++ if (event.event_type === 'network.block.changed') {
++ // Platform blocks: keep the newest revision per pair (an older or replayed one changes nothing).
++ if (event.source !== 'network') return 'ignored:source';
++ const p = networkBlocks.payloadOf(event);
++ if (!p) return 'ignored:payload';
++ return () => (networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');
++ }
if (event.event_type === 'vip.membership.changed') {
// A membership started, lapsed or was revoked: drop that member's cached badge answers for
// that creator now instead of waiting out the cache TTL (VIP convergence).
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex f34323f..820b1fc 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -123,8 +123,8 @@ t('boot with the consumer on and a stub Events', async () => {
t('boot created one subscription per topic, to /internal/events, with the consumer’s secret', async () => {
const results = await h.subscriptions.done;
-- assert.deepStrictEqual(results.map((r) => [r.topic, r.result]), [['live.release.deployed', 'created'], ['network.module.updated', 'created'], ['network.user.token_valid_after', 'created'], ['vip.membership.changed', 'created']]);
-- assert.strictEqual(stubEvents.subs.length, 4);
++ assert.deepStrictEqual(results.map((r) => [r.topic, r.result]), [['live.release.deployed', 'created'], ['network.module.updated', 'created'], ['network.user.token_valid_after', 'created'], ['vip.membership.changed', 'created'], ['network.block.changed', 'created']]);
++ assert.strictEqual(stubEvents.subs.length, 5);
for (const s of stubEvents.subs) {
assert.strictEqual(s.endpoint, `http://127.0.0.1:${h.port}/internal/events`);
assert.strictEqual(s.secret, SECRET, 'the first CHAT_EVENTS_SECRET is handed to Events');
@@@ -139,17 +139,17 @@ t('a second boot changes nothing, and never re-enables a subscription an operato
const endpoint = subs.endpointFor(config, h.port);
const posts = stubEvents.posts;
let r = await subs.ensure({ config, endpoint });
-- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists']);
++ assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists']);
assert.strictEqual(stubEvents.posts, posts, 'nothing was created again');
r = await subs.ensure({ config, endpoint, action: 'disable', topics: ['live.release.deployed'] });
assert.deepStrictEqual(r.map((x) => [x.result, x.enabled]), [['disabled', false]]);
r = await subs.ensure({ config, endpoint });
-- assert.deepStrictEqual(r.map((x) => [x.topic, x.result, x.enabled]), [['live.release.deployed', 'exists', false], ['network.module.updated', 'exists', true], ['network.user.token_valid_after', 'exists', true], ['vip.membership.changed', 'exists', true]]);
++ assert.deepStrictEqual(r.map((x) => [x.topic, x.result, x.enabled]), [['live.release.deployed', 'exists', false], ['network.module.updated', 'exists', true], ['network.user.token_valid_after', 'exists', true], ['vip.membership.changed', 'exists', true], ['network.block.changed', 'exists', true]]);
assert.strictEqual(stubEvents.subs[0].enabled, false, 'still disabled');
r = await subs.ensure({ config, endpoint, action: 'enable', topics: ['live.release.deployed'] });
assert.strictEqual(stubEvents.subs[0].enabled, true);
r = await subs.ensure({ config, endpoint, action: 'list' });
-- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists']);
++ assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists']);
});
t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigned → 401', async () => {
ddiff --git a/test/network-blocks.test.js b/test/network-blocks.test.js
nnew file mode 100644
iindex 0000000..dca69b4
---- /dev/null
++++ b/test/network-blocks.test.js
@@@ -0,0 +1,125 @@
++'use strict';
++/**
++ * Platform blocks in Chat (WS-E task 5, Contracts 0.49.0 network.block.changed): the projection keeps the
++ * newest revision per (blocker, blocked) and ignores older, replayed, foreign or malformed events; while a
++ * block is active, neither person can start a conversation with the other, message them in a 1:1 or add
++ * them to a group, and the user search hides them (both directions), exactly as dm_blocks already does;
++ * an unblock lifts it. The one-off scripts/migrate-dm-blocks-to-network.js maps dm_blocks to subject pairs
++ * (dry run by default) for Network's scripts/import-blocks.js.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { ids, validate } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('network-blocks');
++let h, alice, bob, carol, dave, nosubject, convAB, group;
++const event = (blocker, blocked, active, revision, { source = 'network' } = {}) => ({
++ event_id: ids.newId('event'), event_type: 'network.block.changed', version: 1, source,
++ actor: { type: 'user', id: blocker }, timestamp: new Date().toISOString(), visibility: 'internal',
++ subject: { type: 'user', id: blocker }, payload: { blocker, blocked, active, revision, at: new Date().toISOString() },
++});
++
++t('boot', async () => {
++ h = await boot();
++ alice = h.addUser('alice', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ carol = h.addUser('carol', { subject: ids.newId('user') });
++ dave = h.addUser('dave', { subject: ids.newId('user') });
++ nosubject = h.addUser('legacyonly');
++ await h.ctx.sync();
++ assert.ok(require('../server/events/consumer').TOPICS.includes('network.block.changed'), 'Chat subscribes to it');
++ // Before any block: a 1:1 and a group.
++ convAB = (await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } })).body.conversation.id;
++ group = (await h.http('POST', '/api/dm/conversations', { token: carol.token, body: { user_ids: [bob.id, dave.id], name: 'Crew' } })).body.conversation.id;
++ assert.ok(convAB && group);
++ assert.ok(validate('network.block.changed@1', event(bob.subject_id, alice.subject_id, true, 1).payload).valid);
++});
++
++t('the projection keeps the newest revision per pair', async () => {
++ const nb = require('../server/chat/network-blocks');
++ const e1 = event(bob.subject_id, alice.subject_id, true, 1);
++ assert.strictEqual(h.eventsConsumer.apply(e1).outcome, 'blocked');
++ assert.strictEqual(h.eventsConsumer.apply(e1).duplicate, true, 'a redelivery is a no-op');
++ assert.ok(nb.hasBlocked(bob.subject_id, alice.subject_id));
++ assert.ok(!nb.hasBlocked(alice.subject_id, bob.subject_id), 'one direction');
++ assert.ok(nb.eitherBlockedUsers(alice.id, bob.id) && nb.eitherBlockedUsers(bob.id, alice.id));
++ // Out of order: revision 3 (unblock) then the late revision 2 (block) changes nothing.
++ assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, false, 3)).outcome, 'unblocked');
++ assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 2)).outcome, 'unchanged', 'an older revision never wins');
++ assert.ok(!nb.hasBlocked(dave.subject_id, carol.subject_id));
++ // Only Network's, only valid payloads.
++ assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 9, { source: 'live' })).outcome, 'ignored:source');
++ const bad = event(dave.subject_id, carol.subject_id, true, 9); bad.payload.blocked = 'carol';
++ assert.strictEqual(h.eventsConsumer.apply(bad).outcome, 'ignored:payload');
++ const zero = event(dave.subject_id, carol.subject_id, true, 0);
++ assert.strictEqual(h.eventsConsumer.apply(zero).outcome, 'ignored:payload', 'revision starts at 1');
++ assert.ok(!nb.hasBlocked(dave.subject_id, carol.subject_id));
++});
++
++t('DMs: bob blocked alice on the network — refused both ways, same shapes as dm_blocks', async () => {
++ // A new conversation, either way.
++ let r = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id, carol.id] } });
++ assert.deepStrictEqual([r.status, r.body], [403, { error: 'Cannot start a conversation with this user' }], 'the blocked person cannot start one');
++ r = await h.http('POST', '/api/dm/conversations', { token: bob.token, body: { user_ids: [alice.id] } });
++ assert.deepStrictEqual([r.status, r.body], [403, { error: 'Cannot start a conversation with this user' }], 'nor the blocker');
++ // Messages in their existing 1:1, either way.
++ r = await h.http('POST', `/api/dm/conversations/${convAB}/messages`, { token: alice.token, body: { message: 'hello?' } });
++ assert.deepStrictEqual([r.status, r.body], [403, { error: 'Cannot send messages in this conversation' }]);
++ r = await h.http('POST', `/api/dm/conversations/${convAB}/messages`, { token: bob.token, body: { message: 'go away' } });
++ assert.deepStrictEqual([r.status, r.body], [403, { error: 'Cannot send messages in this conversation' }]);
++ // Being added to a group bob is in.
++ r = await h.http('POST', `/api/dm/conversations/${group}/participants`, { token: carol.token, body: { user_id: alice.id } });
++ assert.deepStrictEqual([r.status, r.body], [403, { error: 'Cannot add this user' }]);
++ // The user search hides them from each other, not from others.
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/users/search?q=bo', { token: alice.token })).body.users, []);
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/users/search?q=ali', { token: bob.token })).body.users, []);
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/users/search?q=ali', { token: carol.token })).body.users.map((u) => u.username), ['alice']);
++ // Others are untouched; the participant check still comes first.
++ r = await h.http('POST', `/api/dm/conversations/${group}/messages`, { token: carol.token, body: { message: 'hi crew' } });
++ assert.strictEqual(r.status, 200);
++ r = await h.http('POST', `/api/dm/conversations/${group}/messages`, { token: alice.token, body: { message: 'let me in' } });
++ assert.deepStrictEqual([r.status, r.body], [403, { error: 'Not a participant' }]);
++ // Chat's own "is blocked" answer stays about dm_blocks (its block button), not the network block.
++ assert.strictEqual((await h.http('GET', `/api/dm/blocks/check/${alice.id}`, { token: bob.token })).body.blocked, false);
++});
++
++t('an unblock lifts it', async () => {
++ assert.strictEqual(h.eventsConsumer.apply(event(bob.subject_id, alice.subject_id, false, 2)).outcome, 'unblocked');
++ let r = await h.http('POST', `/api/dm/conversations/${convAB}/messages`, { token: alice.token, body: { message: 'hi again' } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await h.http('POST', `/api/dm/conversations/${group}/participants`, { token: carol.token, body: { user_id: alice.id } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/users/search?q=bo', { token: alice.token })).body.users.map((u) => u.username), ['bob']);
++});
++
++t('migration: dm_blocks → subject pairs for Network (dry run by default)', async () => {
++ const migrate = require('../scripts/migrate-dm-blocks-to-network');
++ assert.deepStrictEqual((await h.http('POST', `/api/dm/blocks/${bob.id}`, { token: carol.token })).body, { ok: true });
++ await h.http('POST', `/api/dm/blocks/${carol.id}`, { token: dave.token });
++ await h.http('POST', `/api/dm/blocks/${nosubject.id}`, { token: dave.token }); // no Network subject: skipped
++ const out = path.join(h.tmp, 'pairs.json');
++ const lines = [];
++ let code = await migrate.main([], (m) => lines.push(m));
++ assert.strictEqual(code, 0);
++ assert.match(lines.join('\n'), /dm_blocks 3; pairs for Network 2; skipped: blocked-without-subject 1/);
++ assert.match(lines.join('\n'), new RegExp(`Live ids without a Network subject: ${nosubject.id}`));
++ assert.match(lines.join('\n'), /dry run: nothing written/);
++ assert.ok(!fs.existsSync(out));
++ assert.strictEqual(await migrate.main(['--apply'], () => {}), 2, '--apply needs --out');
++ code = await migrate.main(['--apply', '--out', out], () => {});
++ assert.strictEqual(code, 0);
++ const file = JSON.parse(fs.readFileSync(out, 'utf8'));
++ assert.deepStrictEqual(file.pairs, [
++ { blocker_subject: carol.subject_id, blocked_subject: bob.subject_id },
++ { blocker_subject: dave.subject_id, blocked_subject: carol.subject_id },
++ ]);
++ assert.strictEqual(fs.statSync(out).mode & 0o777, 0o600);
++ assert.strictEqual(await migrate.main(['--apply', '--out', out], () => {}), 2, 'never overwrites a file');
++ // Chat's own blocks keep working as before.
++ const r = await h.http('POST', '/api/dm/conversations', { token: bob.token, body: { user_ids: [carol.id] } });
++ assert.deepStrictEqual([r.status, r.body], [403, { error: 'Cannot start a conversation with this user' }]);
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });