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)
Changes
@@@ -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 bridgeop 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.++```bashsudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --dry-run # list themsudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --disable # rollback: no more deliveries@@@ -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",@@@ -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"nnew file mode 100644@@@ -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 };@@@ -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_colorFROM ctx_usersWHERE id != ? AND is_banned = 0AND 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_blocksWHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,[userIdA, userIdB, userIdB, userIdA]-- );++ ) || networkBlocks.eitherBlockedUsers(userIdA, userIdB);}/**nnew file mode 100644@@@ -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 };@@@ -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).@@@ -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 operatoconst 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 () => {nnew file mode 100644@@@ -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(); });
@@ -100,13 +100,14 @@ OpenVibe.Events' `scripts/redact-backfill.js`.
@@ -100,13 +100,14 @@ OpenVibe.Events' `scripts/redact-backfill.js`.
#### Consumed
#### 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`):
(`server/events/consumer.js`):
| Event | From | What Chat does |
| 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) |
| `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.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
- **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,
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
@@ -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_SECRET`, `OV_OAUTH_CLIENT_SECRET` and the Network grant
`chat events.subscription.manage openvibe.events`.
`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
```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 --dry-run # list them
sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --disable # rollback: no more deliveries
sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --disable # rollback: no more deliveries
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"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-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",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -1062,9 +1062,9 @@
@@ -1062,9 +1062,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",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"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-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",
"ws": "^8.18.0"
"ws": "^8.18.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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 };
@@ -13,10 +13,12 @@
@@ -13,10 +13,12 @@
* People are still Live user ids; names and pictures come from the ctx_users projection
* 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
* (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
* 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 db = require('../db/database');
const outbox = require('../events/outbox');
const outbox = require('../events/outbox');
+const networkBlocks = require('./network-blocks');
// ── Schema & Migrations ──────────────────────────────────────
// ── Schema & Migrations ──────────────────────────────────────
@@ -257,28 +259,33 @@ function getTotalUnread(userId) {
@@ -257,28 +259,33 @@ function getTotalUnread(userId) {
*/
*/
function searchUsers(query, excludeUserId, limit = 10) {
function searchUsers(query, excludeUserId, limit = 10) {
if (!query || query.length < 2) return [];
if (!query || query.length < 2) return [];
+ networkBlocks.ensureSchema();
+ const me = db.subjectFor(excludeUserId) || '';
return db.all(`
return db.all(`
SELECT id, username, display_name, avatar_url, profile_color
SELECT id, username, display_name, avatar_url, profile_color
FROM ctx_users
FROM ctx_users
WHERE id != ? AND is_banned = 0
WHERE id != ? AND is_banned = 0
AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_id = ?)
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 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)
AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE)
LIMIT ?
LIMIT ?
- `, [excludeUserId, excludeUserId, excludeUserId, `%${query}%`, `%${query}%`, limit]);
+ `, [excludeUserId, excludeUserId, excludeUserId, me, me, `%${query}%`, `%${query}%`, limit]);
}
}
// ── Block helpers ────────────────────────────────────────────
// ── 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) {
function isBlockedEither(userIdA, userIdB) {
return !!db.get(
return !!db.get(
`SELECT 1 FROM dm_blocks
`SELECT 1 FROM dm_blocks
WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,
WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,
[userIdA, userIdB, userIdB, userIdA]
[userIdA, userIdB, userIdB, userIdA]
- );
+ ) || networkBlocks.eitherBlockedUsers(userIdA, userIdB);
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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 };
@@ -17,6 +17,9 @@
@@ -17,6 +17,9 @@
* vip.membership.changed a membership started, lapsed or was revoked (source vip): the member's cached
* 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
* subscriber-badge answers for that creator are dropped at once
* (../vip/badges.js handleEvent) instead of converging by the cache TTL.
* (../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
* 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
* 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');
@@ -39,11 +42,12 @@ const deployNotice = require('../chat/deploy-notice');
const prefStores = require('../prefs/stores');
const prefStores = require('../prefs/stores');
const vipBadges = require('../vip/badges');
const vipBadges = require('../vip/badges');
const revocations = require('../auth/revocations');
const revocations = require('../auth/revocations');
+const networkBlocks = require('../chat/network-blocks');
const ctx = require('../live-context');
const ctx = require('../live-context');
const CONSUMER = 'chat';
const CONSUMER = 'chat';
const INBOX_TABLE = 'chat_event_inbox';
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 SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[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
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()
@@ -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') {
if (event.event_type === 'vip.membership.changed') {
// A membership started, lapsed or was revoked: drop that member's cached badge answers for
// 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).
// that creator now instead of waiting out the cache TTL (VIP convergence).
@@ -123,8 +123,8 @@ t('boot with the consumer on and a stub Events', async () => {
@@ -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 () => {
t('boot created one subscription per topic, to /internal/events, with the consumer’s secret', async () => {
const results = await h.subscriptions.done;
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) {
for (const s of stubEvents.subs) {
assert.strictEqual(s.endpoint, `http://127.0.0.1:${h.port}/internal/events`);
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');
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
@@ -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 endpoint = subs.endpointFor(config, h.port);
const posts = stubEvents.posts;
const posts = stubEvents.posts;
let r = await subs.ensure({ config, endpoint });
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');
assert.strictEqual(stubEvents.posts, posts, 'nothing was created again');
r = await subs.ensure({ config, endpoint, action: 'disable', topics: ['live.release.deployed'] });
r = await subs.ensure({ config, endpoint, action: 'disable', topics: ['live.release.deployed'] });
assert.deepStrictEqual(r.map((x) => [x.result, x.enabled]), [['disabled', false]]);
assert.deepStrictEqual(r.map((x) => [x.result, x.enabled]), [['disabled', false]]);
r = await subs.ensure({ config, endpoint });
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');
assert.strictEqual(stubEvents.subs[0].enabled, false, 'still disabled');
r = await subs.ensure({ config, endpoint, action: 'enable', topics: ['live.release.deployed'] });
r = await subs.ensure({ config, endpoint, action: 'enable', topics: ['live.release.deployed'] });
assert.strictEqual(stubEvents.subs[0].enabled, true);
assert.strictEqual(stubEvents.subs[0].enabled, true);
r = await subs.ensure({ config, endpoint, action: 'list' });
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 () => {
t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigned → 401', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -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(); });