openvibers/openvibe.network Commits

Platform blocks (WS-E task 5, Contracts 0.49.0): user_blocks by subject with a per-pair revision, /api/v1/me/blocks to list, block and unblock (never yourself or a guest; staff blockable, staff actions never hidden), network.block.changed in the outbox in the same transaction, GET /internal/blocks for Chat and Community (network.blocks.read, token only), no notification from a person the recipient blocked, Blocked people on the account page, and scripts/import-blocks.js for Chat's dm_blocks export

OP OpenVibers committed 25 Sept 2026 · ccbffa75de · 13 files, +652 −9


Changes

ddiff --git a/README.md b/README.md
iindex b8f61cce..cc03d42a 100644
---- a/README.md
++++ b/README.md
@@@ -350,6 +350,26 @@ Namespaces, schemas, writers, public fields and quotas come from openvibe-contra
---
++## Platform blocks
++
++A person blocks someone once, on the network, and every product honours it (roadmap WS-E task 5,
++Contracts 0.49.0; `server/identity/blocks.js`). `user_blocks` is keyed by subjects, one row per
++(blocker, blocked) with `active` and a per-pair `revision` that grows on every change.
++
++- People: `GET /api/v1/me/blocks` (who I blocked, with names and avatars), `PUT|DELETE /api/v1/me/blocks/:subjectOrUsername`.
++ Nobody blocks themselves; guests neither block nor are blocked. Staff can be blocked, but a block never hides a
++ staff action. The account page (Security → Blocked people) lists blocks with unblock buttons.
++- Services: `GET /internal/blocks?subject=usr_…` → `{ subject, blocks, blocked_by }`, service token with
++ `network.blocks.read` only (Chat and Community hold it by default; never the shared key).
++- Every change writes `network.block.changed` into `network_event_outbox` in the same transaction. Chat
++ (DMs, mentions) and Community (replies) keep projections from their own Events subscriptions.
++- Network's notifications: nothing is created from a person the recipient blocked (`sender_id` as a Network
++ id, or `actor_subject`); moderation, system and admin notices always are.
++- Import (once, from Chat's `dm_blocks` export): `node scripts/import-blocks.js --file <json>` (dry run),
++ then `--apply`. Pairs Network already knows are left alone.
++
++---
++
## Multi-Account Switching
Google-style account management supporting up to 5 accounts:
@@@ -550,7 +570,8 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
| `dev_audit` | Append-only developer audit; rows that are platform events carry an event envelope |
| `analytics_events`, `analytics_hourly`, `analytics_daily` | Request analytics (raw ≤ 30 days) and rollups, within ADR-021; see [Analytics](#analytics-adr-021) |
| `user_modules`, `user_module_revisions`, `user_module_retirements` | User-module records, the last revision issued per (subject, namespace), retired namespace owners |
--| `network_event_outbox` | Network's events on their way to OpenVibe.Events (developer projects, user modules) |
++| `network_event_outbox` | Network's events on their way to OpenVibe.Events (developer projects, user modules, blocks) |
++| `user_blocks` | Platform blocks by subject (blocker, blocked, active, per-pair revision) |
| `analytics_visitor_days`, `analytics_day_salts` | The current day's salted visitor hashes and salt, deleted after the day's final rollup |
---
ddiff --git a/package-lock.json b/package-lock.json
iindex 929c34e6..69ea2fa2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.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",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.48.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.0",
-- "integrity": "sha512-RhVy8Sn6yej9U96tyvZe+QeDrBiYJypue2l/J8AufkA/t3RFRIjeyDcwCWTD2JXXB9eGcIO9dnKdprR0UNQ60Q==",
++ "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 bda1ea33..12522f46 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.48.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",
"uuid": "^11.1.0",
ddiff --git a/public/my.html b/public/my.html
iindex 3d8ad518..142fe59d 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -501,6 +501,16 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<button class="btn" onclick="signOutEverywhere()"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere, this device too</button>
</div>
</div>
++ <div class="card" id="blocks-card">
++ <h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Blocked people</h3>
++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">People you block cannot message you, mention you or reply to what you post on OpenVibe sites, and you get no notifications from them. Moderators' actions still reach you.</p>
++ <form id="block-form" onsubmit="blockPerson(event)" style="margin-bottom:12px">
++ <div class="form-field"><label for="block-username">Block someone</label><input id="block-username" name="username" maxlength="64" autocomplete="off" placeholder="username"></div>
++ <div class="btn-row"><button class="btn btn-danger" type="submit">Block</button></div>
++ </form>
++ <div id="blocks-list"><div class="loading-spinner">Loading…</div></div>
++ <p class="data-status" id="blocks-status" role="status"></p>
++ </div>
</div>
<!-- Anon Info Section -->
@@@ -1117,6 +1127,7 @@ async function loadUser() {
loadNotifPrefs();
loadLinked();
loadSessions();
++ loadBlocks();
loadAnonInfo();
loadEmailStatus();
loadRecentTools();
@@@ -1507,6 +1518,60 @@ async function loadSessions() {
} catch (err) { document.getElementById('sessions-list').innerHTML = '<p style="color:var(--text-muted)">Unable to load sessions.</p>'; }
}
++// Blocked people (platform blocks: /api/v1/me/blocks), built from DOM nodes.
++async function loadBlocks() {
++ const box = document.getElementById('blocks-list');
++ if (!box) return;
++ if (isAnonSession()) { box.textContent = 'Anonymous identities cannot block people.'; return; }
++ try {
++ const data = await apiFetch('/api/v1/me/blocks');
++ box.replaceChildren();
++ if (!(data.blocks || []).length) {
++ const p = document.createElement('p'); p.style.cssText = 'color:var(--text-muted);font-size:13px'; p.textContent = 'You have not blocked anyone.';
++ box.appendChild(p); return;
++ }
++ for (const b of data.blocks) {
++ const row = document.createElement('div'); row.className = 'module-row';
++ const main = document.createElement('div'); main.className = 'module-row-main';
++ const title = document.createElement('strong'); title.textContent = b.display_name || b.username || 'Deleted account';
++ const info = document.createElement('small');
++ info.textContent = `${b.username ? '@' + b.username + ' · ' : ''}blocked ${b.blocked_at ? new Date(b.blocked_at).toLocaleDateString() : 'recently'}`;
++ main.append(title, info);
++ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-small';
++ btn.textContent = 'Unblock'; btn.setAttribute('aria-label', 'Unblock ' + (b.username || b.subject));
++ btn.addEventListener('click', () => unblockPerson(b, btn));
++ row.append(main, btn);
++ box.appendChild(row);
++ }
++ } catch { box.textContent = 'Could not load the people you blocked. Try again later.'; }
++}
++async function blockPerson(e) {
++ e.preventDefault();
++ const input = document.getElementById('block-username');
++ const status = document.getElementById('blocks-status');
++ const name = input.value.trim().replace(/^@/, '');
++ if (!name) return;
++ try {
++ await apiFetch('/api/v1/me/blocks/' + encodeURIComponent(name), { method: 'PUT' });
++ if (status) status.textContent = `Blocked @${name}.`;
++ input.value = '';
++ } catch (err) {
++ if (status) status.textContent = err.status === 404 ? 'Nobody has that username.' : err.status === 400 ? 'You cannot block that account.' : 'Could not block. Try again.';
++ }
++ loadBlocks();
++}
++async function unblockPerson(b, btn) {
++ const status = document.getElementById('blocks-status');
++ btn.disabled = true;
++ try {
++ await apiFetch('/api/v1/me/blocks/' + encodeURIComponent(b.subject), { method: 'DELETE' });
++ if (status) status.textContent = `Unblocked ${b.username ? '@' + b.username : 'that account'}.`;
++ } catch (e) {
++ if (status) status.textContent = e.status === 404 ? 'Already unblocked.' : 'Could not unblock. Try again.';
++ }
++ loadBlocks();
++}
++
async function revokeSession(id) {
try { await apiFetch('/api/auth/sessions/' + id, { method: 'DELETE' }); loadSessions(); }
catch (err) { alert('Error: ' + err.message); }
ddiff --git a/scripts/import-blocks.js b/scripts/import-blocks.js
nnew file mode 100644
iindex 00000000..dfc9a9bc
---- /dev/null
++++ b/scripts/import-blocks.js
@@@ -0,0 +1,97 @@
++#!/usr/bin/env node
++/**
++ * Import blocks made elsewhere into platform blocks (roadmap WS-E task 5). The input is the JSON file
++ * OpenVibe.Chat's scripts/migrate-dm-blocks-to-network.js writes from its dm_blocks table:
++ *
++ * { "pairs": [{ "blocker_subject": "usr_…", "blocked_subject": "usr_…" }, …] } (a bare array works too)
++ *
++ * node scripts/import-blocks.js --file <json> # dry run: what would be imported, nothing changes
++ * node scripts/import-blocks.js --file <json> --apply # insert the blocks and queue their events
++ * --db <path> default $DB_PATH, else data/network.db (relative to the repo root)
++ *
++ * A pair is imported only when Network has never seen it: an existing row (blocked, or unblocked since)
++ * is the person's later decision and is left alone. Pairs naming an unknown account, a guest or the same
++ * person twice are skipped and counted. Each import is a normal block (server/identity/blocks.js setBlock):
++ * revision 1 and network.block.changed in network_event_outbox in the same transaction; the running
++ * server relays those rows within its next poll. Safe to run twice (the second run imports nothing).
++ * Safe while Network runs (busy_timeout 5 s). Run as the database's owner; run as root it becomes
++ * that owner before opening the database. Undo a pair with DELETE /api/v1/me/blocks/:subject as the person.
++ */
++'use strict';
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const ops = require('./lib/db-ops');
++const blocks = require('../server/identity/blocks');
++
++const USAGE = 'usage: node scripts/import-blocks.js --file <json> [--apply] [--db <path>]';
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function readPairs(file) {
++ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
++ const list = Array.isArray(data) ? data : data && Array.isArray(data.pairs) ? data.pairs : null;
++ if (!list) throw new Error('the file must hold { pairs: [...] } or an array of { blocker_subject, blocked_subject }');
++ return list;
++}
++
++/** Sort each pair into import / skip. → { todo: [{ blocker, blocked }], skipped: { reason: n } } */
++function plan(db, pairs) {
++ const person = db.prepare('SELECT is_anon FROM users WHERE subject_id = ?');
++ const seen = db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?');
++ const todo = [];
++ const skipped = {};
++ const skip = (why) => { skipped[why] = (skipped[why] || 0) + 1; };
++ const dup = new Set();
++ for (const p of pairs) {
++ const blocker = p && String(p.blocker_subject || '');
++ const blocked = p && String(p.blocked_subject || '');
++ if (!SUBJECT_RE.test(blocker) || !SUBJECT_RE.test(blocked)) { skip('not-a-subject'); continue; }
++ if (blocker === blocked) { skip('self'); continue; }
++ const key = `${blocker}>${blocked}`;
++ if (dup.has(key)) { skip('duplicate-in-file'); continue; }
++ dup.add(key);
++ const a = person.get(blocker);
++ const b = person.get(blocked);
++ if (!a || !b) { skip('unknown-account'); continue; }
++ if (a.is_anon || b.is_anon) { skip('guest'); continue; }
++ if (seen.get(blocker, blocked)) { skip('already-on-network'); continue; }
++ todo.push({ blocker, blocked });
++ }
++ return { todo, skipped };
++}
++
++async function main(argv, log = console.log) {
++ let args;
++ try { args = ops.parseArgs(argv, { flags: ['apply'], values: ['db', 'file'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
++ if (args.help) { log(USAGE); return 0; }
++ if (!args.file) { log(USAGE); return 2; }
++ const file = ops.dbPath(args);
++ if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }
++ let pairs;
++ try { pairs = readPairs(path.resolve(process.cwd(), args.file)); } catch (e) { log(`error: ${e.message}`); return 2; }
++ ops.dropToOwnerOf(file, log);
++ const db = new Database(file);
++ db.pragma('busy_timeout = 5000');
++ try {
++ blocks.ensureSchema(db);
++ const { todo, skipped } = plan(db, pairs);
++ const why = Object.entries(skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
++ log(`database ${file}\npairs ${pairs.length}; to import ${todo.length}; skipped: ${why}`);
++ if (!args.apply) { log(todo.length ? 'dry run: nothing changed. Re-run with --apply to import.' : 'nothing to import.'); return 0; }
++ let imported = 0;
++ let refused = 0;
++ for (const p of todo) {
++ try { if (blocks.setBlock(db, p.blocker, p.blocked, true).changed) imported++; } catch (e) {
++ if (!(e instanceof blocks.BlockError)) throw e;
++ refused++;
++ log(`refused ${p.blocker} -> ${p.blocked}: ${e.message}`);
++ }
++ }
++ log(`imported ${imported} block(s); ${imported} network.block.changed event(s) queued in network_event_outbox${refused ? `; refused ${refused}` : ''}`);
++ return refused ? 1 : 0;
++ } finally { db.close(); }
++}
++
++if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
++
++module.exports = { main, plan, readPairs };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f3565eb9..e213d367 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -741,6 +741,8 @@ function initDb(dbPath) {
require('../identity/modules').ensureSchema(db);
// Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).
require('../developer/store').ensureSchema(db);
++ // Platform blocks, keyed by subjects (server/identity/blocks.js).
++ require('../identity/blocks').ensureSchema(db);
console.log('[DB] Central database initialized');
return db;
ddiff --git a/server/identity/blocks.js b/server/identity/blocks.js
nnew file mode 100644
iindex 00000000..8b420a29
---- /dev/null
++++ b/server/identity/blocks.js
@@@ -0,0 +1,199 @@
++'use strict';
++/**
++ * Platform blocks (roadmap WS-E task 5; Contracts 0.49.0 network.block.changed, network.blocks.read).
++ *
++ * A person blocks someone once, here, and every product honours it: Chat (no DM, no mention notification
++ * from someone who blocked you), Community (no reply to their threads, comments or pastes) and Network's own
++ * notifications (none from a person the recipient blocked; server/notifications/notification-service.js).
++ *
++ * user_blocks is keyed by subjects (usr_…), one row per (blocker, blocked). An unblock keeps the row with
++ * active = 0 so the pair's revision keeps growing: every change raises it by one, and consumers keep the
++ * highest revision they have applied. Each change writes network.block.changed into network_event_outbox in
++ * the same transaction (relayed to OpenVibe.Events by server/developer/event-relay.js); setting a pair to the
++ * state it already has changes nothing and announces nothing.
++ *
++ * Nobody blocks themselves, and guests (anonymous sessions) neither block nor are blocked. Staff can be
++ * blocked like anyone (no DMs, mentions or replies from them), but a block never hides a staff action:
++ * moderation, system and admin notices are always delivered, and services apply bans, removals and
++ * warnings whatever the blocks.
++ *
++ * GET /api/v1/me/blocks who I blocked (subject, username, display name, avatar)
++ * PUT /api/v1/me/blocks/:subjectOrUsername block (201 new, 200 already blocked)
++ * DELETE /api/v1/me/blocks/:subjectOrUsername unblock (200; 404 when not blocked)
++ * GET /internal/blocks?subject=usr_… { subject, blocks, blocked_by } for services holding
++ * network.blocks.read (service token only, never the key)
++ */
++const express = require('express');
++const { ids, validate, http } = require('openvibe-contracts');
++const eventRelay = require('../developer/event-relay');
++
++const EVENT_TYPE = 'network.block.changed';
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const NAME_RE = /^[A-Za-z0-9_]{1,64}$/;
++// Active blocks one person may hold; keeps /internal/blocks answers bounded.
++const MAX_ACTIVE = 5000;
++
++class BlockError extends Error {
++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
++}
++
++function ensureSchema(db) {
++ db.exec(`CREATE TABLE IF NOT EXISTS user_blocks (
++ blocker_subject TEXT NOT NULL,
++ blocked_subject TEXT NOT NULL,
++ active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
++ revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
++ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
++ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
++ PRIMARY KEY (blocker_subject, blocked_subject),
++ CHECK (blocker_subject <> blocked_subject)
++ );
++ CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_subject, active);`);
++}
++
++/** A person who can block or be blocked: a users row with a usr_ subject that is not a guest. */
++const personRow = (db, where, value) => db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);
++
++/** The person a path segment names: a usr_ subject, or a current username (any case). */
++function findTarget(db, key) {
++ const k = String(key || '').trim();
++ if (SUBJECT_RE.test(k)) return personRow(db, 'subject_id = ?', k) || null;
++ if (/^gst_/.test(k)) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');
++ if (!NAME_RE.test(k)) return null;
++ return personRow(db, 'username = ? COLLATE NOCASE', k) || null;
++}
++
++function isBlocked(db, blockerSubject, blockedSubject) {
++ return !!db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(String(blockerSubject), String(blockedSubject));
++}
++
++/** The envelope for one change; throws if it would not match the contract (a bug, never input). */
++function buildEnvelope({ blocker, blocked, active, revision, at }) {
++ const ms = Date.parse(at) || Date.now();
++ const env = {
++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
++ actor: { type: 'user', id: blocker }, timestamp: new Date(ms).toISOString(), visibility: 'internal',
++ subject: { type: 'user', id: blocker },
++ payload: { blocker, blocked, active: !!active, revision, at: new Date(ms).toISOString() },
++ };
++ const v = validate('events.event-envelope@1', env);
++ const pv = validate(`${EVENT_TYPE}@1`, env.payload);
++ if (!v.valid || !pv.valid) throw new Error(`blocks: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
++ return env;
++}
++
++/**
++ * Set (blocker, blocked) to `active`, in one transaction with its event. Both must be usr_ subjects
++ * (the caller checked who they are). → { changed, active, revision, at }
++ */
++function setBlock(db, blocker, blocked, active) {
++ if (!SUBJECT_RE.test(String(blocker)) || !SUBJECT_RE.test(String(blocked))) throw new BlockError(400, 'blocks.bad_subject', 'blocks are between usr_ subjects');
++ if (blocker === blocked) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');
++ return db.transaction(() => {
++ const prev = db.prepare('SELECT active, revision, updated_at FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?').get(blocker, blocked);
++ if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };
++ if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };
++ if (active) {
++ const n = db.prepare('SELECT COUNT(*) AS n FROM user_blocks WHERE blocker_subject = ? AND active = 1').get(blocker).n;
++ if (n >= MAX_ACTIVE) throw new BlockError(409, 'blocks.limit', `at most ${MAX_ACTIVE} people can be blocked`);
++ }
++ const at = new Date().toISOString();
++ const revision = prev ? prev.revision + 1 : 1;
++ if (prev) {
++ db.prepare('UPDATE user_blocks SET active = ?, revision = ?, updated_at = ? WHERE blocker_subject = ? AND blocked_subject = ?')
++ .run(active ? 1 : 0, revision, at, blocker, blocked);
++ } else {
++ db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')
++ .run(blocker, blocked, at, at);
++ }
++ eventRelay.writerFor(db).enqueue(buildEnvelope({ blocker, blocked, active, revision, at }));
++ return { changed: true, active: !!active, revision, at };
++ })();
++}
++
++function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
++
++/** Who `subject` blocked (with names, newest first), for the person's own list. */
++function listFor(db, subject) {
++ return db.prepare(`SELECT b.blocked_subject, b.revision, b.updated_at, u.username, u.display_name, u.avatar_url
++ FROM user_blocks b LEFT JOIN users u ON u.subject_id = b.blocked_subject
++ WHERE b.blocker_subject = ? AND b.active = 1 ORDER BY b.updated_at DESC, b.blocked_subject`).all(String(subject))
++ .map((r) => ({
++ subject: r.blocked_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,
++ avatar_url: r.avatar_url || null, blocked_at: r.updated_at, revision: r.revision,
++ }));
++}
++
++/** What a service needs: who `subject` blocked and who blocked them (active blocks only). */
++function edgesOf(db, subject) {
++ const s = String(subject);
++ return {
++ subject: s,
++ blocks: db.prepare('SELECT blocked_subject AS s FROM user_blocks WHERE blocker_subject = ? AND active = 1 ORDER BY blocked_subject').all(s).map((r) => r.s),
++ blocked_by: db.prepare('SELECT blocker_subject AS s FROM user_blocks WHERE blocked_subject = ? AND active = 1 ORDER BY blocker_subject').all(s).map((r) => r.s),
++ };
++}
++
++/** The signed-in person as a blocker, or a BlockError. */
++function meOf(db, user) {
++ if (!user || user.is_anon) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');
++ const sid = require('./subjects').ensureUserSubject(db, user);
++ if (!SUBJECT_RE.test(String(sid || ''))) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');
++ return sid;
++}
++
++const view = (u, out) => ({ subject: u.subject_id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, active: out.active, revision: out.revision, ...(out.active ? { blocked_at: out.at } : {}) });
++
++function userRouter(requireAuth) {
++ const router = express.Router();
++ router.use(http.middleware());
++ const send = (req, res, fn) => {
++ res.set('Cache-Control', 'private, no-store');
++ try { return fn(req.app.locals.db); } catch (err) {
++ if (err instanceof BlockError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
++ console.error('[Blocks]', err.message);
++ return http.sendProblem(res, 500, 'blocks.failed', { detail: 'the block could not be changed', ctx: req.ov });
++ }
++ };
++
++ router.get('/', requireAuth, (req, res) => send(req, res, (db) => {
++ const me = meOf(db, req.user);
++ res.json({ subject: me, blocks: listFor(db, me) });
++ }));
++
++ router.put('/:target', requireAuth, (req, res) => send(req, res, (db) => {
++ const me = meOf(db, req.user);
++ const u = findTarget(db, req.params.target);
++ if (!u) throw new BlockError(404, 'blocks.unknown_person', 'no such person');
++ if (u.is_anon) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');
++ if (u.subject_id === me) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');
++ const out = setBlock(db, me, u.subject_id, true);
++ if (out.changed) kick(db);
++ res.status(out.changed ? 201 : 200).json({ changed: out.changed, block: view(u, out) });
++ }));
++
++ router.delete('/:target', requireAuth, (req, res) => send(req, res, (db) => {
++ const me = meOf(db, req.user);
++ const key = String(req.params.target || '');
++ // A subject can be unblocked even if its account is gone since.
++ const u = findTarget(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null, avatar_url: null } : null);
++ if (!u || !u.subject_id || !isBlocked(db, me, u.subject_id)) throw new BlockError(404, 'blocks.not_blocked', 'that person is not blocked');
++ const out = setBlock(db, me, u.subject_id, false);
++ if (out.changed) kick(db);
++ res.json({ changed: out.changed, block: view(u, out) });
++ }));
++
++ return router;
++}
++
++/** GET /internal/blocks?subject=usr_… (mounted behind principals.guard('network.blocks.read', { legacy: false })). */
++function internalHandler(db) {
++ return (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ const subject = String(req.query.subject || '');
++ if (!SUBJECT_RE.test(subject)) return http.sendProblem(res, 400, 'blocks.bad_subject', { detail: 'subject must be a usr_ subject id' });
++ res.json(edgesOf(db, subject));
++ };
++}
++
++module.exports = { EVENT_TYPE, MAX_ACTIVE, BlockError, ensureSchema, setBlock, isBlocked, listFor, edgesOf, findTarget, buildEnvelope, userRouter, internalHandler, kick };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 8e437141..630d0283 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -168,6 +168,10 @@ const DEFAULT_GRANTS = [
// Chat consumes live.release.deployed (the deploy card, register C-84) and network.module.updated
// (its chat.preferences cache) through its own Events subscriptions, created at Chat's boot.
['chat', 'events.subscription.manage', 'openvibe.events', []],
++ // Platform blocks (WS-E task 5): Chat (DMs, mentions) and Community (replies) read who blocked whom;
++ // both also follow network.block.changed through their own Events subscriptions.
++ ['chat', 'network.blocks.read', SELF_AUDIENCE, []],
++ ['community', 'network.blocks.read', SELF_AUDIENCE, []],
['live', 'chat.live_bridge.write', 'openvibe.chat', []],
['live', 'chat.presence.read', 'openvibe.chat', []],
['live', 'chat.message.send', 'openvibe.chat', []],
ddiff --git a/server/index.js b/server/index.js
iindex 689096ec..af082de4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -397,6 +397,10 @@ app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }),
// The GitHub token (admin → Settings → GitHub, or GITHUB_TOKEN): owner-only admin, and Blog's changelog reads it.
app.use('/api/admin/integrations/github', requireAuth, require('./integrations/github').adminRouter(db));
app.get('/internal/integrations/github-token', require('./identity/principals').guard('network.integration.github.read', { legacy: false }), require('./integrations/github').internalHandler(db));
++// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community
++// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).
++app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));
++app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read', { legacy: false }), require('./identity/blocks').internalHandler(db));
// ── Routes ───────────────────────────────────────────────────
// Public key endpoint (services fetch this to verify JWTs).
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 00972810..beccdfe0 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -417,7 +417,9 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
try {
const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;
if (link) notifData.sender_id = link.user_id;
-- } catch { /* keep Live id */ }
++ // A Live id is not a Network person: no block check against whoever has that Network id.
++ else notifData.actor_subject = null;
++ } catch { notifData.actor_subject = null; /* keep Live id */ }
// Find users who opted into "all live" notifications
const allLiveUserIds = streamLive.allLiveSubscribers(db);
ddiff --git a/server/notifications/notification-service.js b/server/notifications/notification-service.js
iindex 7ce44a55..a9812e60 100644
---- a/server/notifications/notification-service.js
++++ b/server/notifications/notification-service.js
@@@ -9,6 +9,9 @@
const { v4: uuidv4 } = require('uuid');
const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');
++// Staff and platform notices: never hidden by a block.
++const BLOCK_EXEMPT_CATEGORIES = new Set(['moderation', 'system', 'admin']);
++
class NotificationService {
constructor(db) {
this.db = db;
@@@ -108,6 +111,18 @@ class NotificationService {
SELECT COUNT(*) AS c FROM email_delivery_log WHERE status = 'sent' AND created_at > datetime('now', '-1 day')
`);
++ // Platform blocks (server/identity/blocks.js): the recipient blocked the actor, named by subject or
++ // by Network user id (sender_id).
++ require('../identity/blocks').ensureSchema(db);
++ this._blockedActorSubject = db.prepare(`
++ SELECT 1 FROM user_blocks b JOIN users r ON r.subject_id = b.blocker_subject
++ WHERE r.id = ? AND b.blocked_subject = ? AND b.active = 1 LIMIT 1
++ `);
++ this._blockedActorId = db.prepare(`
++ SELECT 1 FROM user_blocks b JOIN users r ON r.subject_id = b.blocker_subject JOIN users s ON s.subject_id = b.blocked_subject
++ WHERE r.id = ? AND s.id = ? AND b.active = 1 LIMIT 1
++ `);
++
this._newestForUser = db.prepare(`
SELECT * FROM notifications
WHERE user_id = ? AND is_dismissed = 0
@@@ -133,6 +148,9 @@ class NotificationService {
// Check user's preferences — skip if disabled
const pref = this._getPrefByCategory.get(data.user_id, category);
if (pref && !pref.enabled) return null;
++ // Nothing from a person the recipient blocked (platform blocks, WS-E task 5). A block never hides a
++ // staff action: moderation, system and admin notices are always created.
++ if (!BLOCK_EXEMPT_CATEGORIES.has(category) && this.fromBlockedActor(data)) return null;
// Go-live dedupe (see _recentFromSender).
if (data.type === 'STREAM_LIVE' && data.sender_id != null) {
try { if (this._recentFromSender.get(data.user_id, 'STREAM_LIVE', data.sender_id)) return null; } catch { /* */ }
@@@ -163,6 +181,20 @@ class NotificationService {
return { id, user_id: data.user_id, type: data.type, category, priority, title, message: data.message, icon, service: data.service, url: data.url, rich_content: data.rich_content, is_read: 0, created_at: new Date().toISOString() };
}
++ /**
++ * Whether the recipient (data.user_id) blocked the notification's actor: data.actor_subject (a usr_
++ * subject; null = no known person) when given, else data.sender_id as a Network user id.
++ */
++ fromBlockedActor(data) {
++ try {
++ if (Object.prototype.hasOwnProperty.call(data, 'actor_subject')) {
++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(data.actor_subject || '')) && !!this._blockedActorSubject.get(data.user_id, data.actor_subject);
++ }
++ const sender = Number(data.sender_id);
++ return Number.isInteger(sender) && sender > 0 && Number(data.user_id) !== sender && !!this._blockedActorId.get(data.user_id, sender);
++ } catch { return false; }
++ }
++
/**
* Bulk-create notifications for multiple users (e.g., broadcast).
*/
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
nnew file mode 100644
iindex 00000000..0709a188
---- /dev/null
++++ b/test/blocks.test.js
@@@ -0,0 +1,217 @@
++'use strict';
++// Platform blocks (roadmap WS-E task 5; Contracts 0.49.0): a person blocks and unblocks through
++// /api/v1/me/blocks (never themselves, never a guest), each change writes network.block.changed into the
++// outbox with a growing per-pair revision, GET /internal/blocks answers services holding network.blocks.read
++// (service token only), Network creates no notification from a person the recipient blocked (staff and
++// system notices still arrive), and scripts/import-blocks.js imports Chat's exported dm_blocks once.
++// node test/blocks.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { validate } = require('openvibe-contracts');
++const { initDb } = require('../server/db/database');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-blocks-'));
++const dbFile = path.join(dir, 'network.db');
++const log = console.log; console.log = () => {};
++const db = initDb(dbFile);
++console.log = log;
++// chat and community are registered on the host, not seeded: copies of tools, then their grants seeded again.
++for (const c of ['chat', 'community']) {
++ const cols = db.prepare('PRAGMA table_info(oauth_clients)').all().map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');
++ db.prepare(`INSERT OR IGNORE INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools'`).run(c);
++}
++require('../server/identity/principals').ensureSchema(db);
++for (const c of ['chat', 'community', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
++
++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
++const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
++const MOD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
++db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES
++ (1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob <b>', 'x', ?, 'user'), (3, 'cat', NULL, 'x', ?, 'user'), (4, 'mod', 'Mod', 'x', ?, 'global_mod')`).run(ANN, BOB, CAT, MOD);
++db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();
++const GUEST_SUBJECT = db.prepare('SELECT subject_id FROM users WHERE id = 5').get().subject_id;
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const { signToken } = require('../server/auth/routes');
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
++const blocks = require('../server/identity/blocks');
++const principals = require('../server/identity/principals');
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/api/v1/me/blocks', blocks.userRouter(requireAuth));
++app.get('/internal/blocks', principals.guard('network.blocks.read', { legacy: false }), blocks.internalHandler(db));
++app.use('/internal', require('../server/internal/routes'));
++const server = http.createServer(app);
++
++const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.block.changed');
++const valid = (e) => {
++ const v = validate('events.event-envelope@1', e);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ const pv = validate('network.block.changed@1', e.payload);
++ assert.ok(pv.valid, JSON.stringify(pv.errors));
++ assert.deepStrictEqual([e.source, e.visibility, e.subject, e.actor], ['network', 'internal', { type: 'user', id: e.payload.blocker }, { type: 'user', id: e.payload.blocker }]);
++};
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tok = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
++ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
++ .then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
++ const as = (id) => ({ authorization: `Bearer ${tok(id)}` });
++ const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json())).access_token;
++
++ // ── A person blocks and unblocks ──
++ let r = await call('GET', '/api/v1/me/blocks');
++ assert.strictEqual(r.status, 401, 'signed in only');
++ r = await call('GET', '/api/v1/me/blocks', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.subject, r.body.blocks], [200, ANN, []]);
++ assert.ok(r.cache.includes('no-store'));
++
++ r = await call('PUT', '/api/v1/me/blocks/BOB', { headers: as(1) });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ assert.deepStrictEqual([r.body.changed, r.body.block.subject, r.body.block.username, r.body.block.active, r.body.block.revision], [true, BOB, 'bob', true, 1]);
++ r = await call('PUT', `/api/v1/me/blocks/${BOB}`, { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.changed, r.body.block.revision], [200, false, 1], 'blocking again changes nothing');
++ assert.strictEqual(events().length, 1, 'and announces nothing');
++ r = await call('PUT', '/api/v1/me/blocks/mod', { headers: as(1) });
++ assert.strictEqual(r.status, 201, 'staff can be blocked');
++
++ r = await call('GET', '/api/v1/me/blocks', { headers: as(1) });
++ assert.deepStrictEqual(r.body.blocks.map(b => b.username).sort(), ['bob', 'mod']);
++ const bob = r.body.blocks.find(b => b.username === 'bob');
++ assert.deepStrictEqual([bob.subject, bob.display_name], [BOB, 'Bob <b>'], 'names are data; the page renders them as text');
++ assert.ok(bob.blocked_at);
++
++ // Refusals: yourself, a guest (by subject or name), nobody, not signed in as a person.
++ r = await call('PUT', '/api/v1/me/blocks/ann', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);
++ r = await call('PUT', `/api/v1/me/blocks/${ANN}`, { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);
++ r = await call('PUT', '/api/v1/me/blocks/anon1234', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);
++ r = await call('PUT', '/api/v1/me/blocks/gst_01JAB2C3D4E5F6G7H8J9K0MNPQ', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);
++ if (/^usr_/.test(GUEST_SUBJECT || '')) {
++ r = await call('PUT', `/api/v1/me/blocks/${GUEST_SUBJECT}`, { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest'], 'a guest users row, by its subject');
++ }
++ r = await call('PUT', '/api/v1/me/blocks/nobody_here', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.unknown_person']);
++ r = await call('PUT', '/api/v1/me/blocks/bob', { headers: as(5) });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'blocks.guest'], 'a guest cannot block');
++ assert.throws(() => blocks.setBlock(db, ANN, ANN, true), /yourself/);
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_blocks').get().n, 2, 'refusals wrote nothing');
++
++ // Unblock, block again: the pair's revision grows every time.
++ r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.changed, r.body.block.active, r.body.block.revision], [200, true, false, 2]);
++ r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.not_blocked']);
++ r = await call('PUT', '/api/v1/me/blocks/bob', { headers: as(1) });
++ assert.deepStrictEqual([r.status, r.body.block.revision], [201, 3]);
++ r = await call('PUT', '/api/v1/me/blocks/ann', { headers: as(3) });
++ assert.strictEqual(r.status, 201, 'cat blocks ann');
++
++ // ── Events: one per change, valid, in order, revisions growing per pair ──
++ const evs = events();
++ evs.forEach(valid);
++ const annBob = evs.filter(e => e.payload.blocker === ANN && e.payload.blocked === BOB).map(e => [e.payload.active, e.payload.revision]);
++ assert.deepStrictEqual(annBob, [[true, 1], [false, 2], [true, 3]]);
++ assert.deepStrictEqual(evs.map(e => e.payload.blocked), [BOB, MOD, BOB, BOB, ANN]);
++ assert.strictEqual(new Set(evs.map(e => e.event_id)).size, evs.length);
++
++ // In the same transaction: a change that fails after the row is written leaves neither row nor event.
++ const before = events().length;
++ const enqueue = require('../server/developer/event-relay').writerFor(db);
++ const real = enqueue.enqueue;
++ enqueue.enqueue = () => { throw new Error('outbox down'); };
++ assert.throws(() => blocks.setBlock(db, CAT, BOB, true), /outbox down/);
++ enqueue.enqueue = real;
++ assert.strictEqual(blocks.isBlocked(db, CAT, BOB), false, 'rolled back with its event');
++ assert.strictEqual(events().length, before);
++
++ // ── /internal/blocks for services ──
++ const chat = await svc('chat');
++ const community = await svc('community');
++ r = await call('GET', `/internal/blocks?subject=${ANN}`, { headers: { authorization: `Bearer ${chat}` } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual(r.body, { subject: ANN, blocks: [BOB, MOD].sort(), blocked_by: [CAT] });
++ r = await call('GET', `/internal/blocks?subject=${BOB}`, { headers: { authorization: `Bearer ${community}` } });
++ assert.deepStrictEqual(r.body, { subject: BOB, blocks: [], blocked_by: [ANN] });
++ r = await call('GET', '/internal/blocks?subject=bob', { headers: { authorization: `Bearer ${chat}` } });
++ assert.strictEqual(r.status, 400, 'subjects only');
++ const tools = await svc('tools');
++ r = await call('GET', `/internal/blocks?subject=${ANN}`, { headers: { authorization: `Bearer ${tools}` } });
++ assert.strictEqual(r.status, 403, 'tools does not hold network.blocks.read');
++ r = await call('GET', `/internal/blocks?subject=${ANN}`, { headers: { 'x-internal-key': 'legacy-key' } });
++ assert.ok([401, 403].includes(r.status), 'never the shared key');
++ r = await call('GET', `/internal/blocks?subject=${ANN}`);
++ assert.ok([401, 403].includes(r.status), 'no credential');
++ for (const c of ['chat', 'community']) assert.ok(principals.grantsFor(db, c, 'openvibe.network').some(g => g.capability === 'network.blocks.read'), `${c} holds network.blocks.read`);
++
++ // ── Network's own notifications ──
++ const { NotificationService } = require('../server/notifications/notification-service');
++ const notes = new NotificationService(db);
++ const count = (uid) => db.prepare('SELECT COUNT(*) AS n FROM notifications WHERE user_id = ?').get(uid).n;
++ assert.strictEqual(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, sender_name: 'bob', message: 'bob followed you' }), null, 'ann blocked bob: nothing from him');
++ assert.strictEqual(notes.create({ user_id: 1, type: 'MENTION', actor_subject: BOB, message: 'bob mentioned you' }), null, 'nor when the actor is named by subject');
++ assert.strictEqual(count(1), 0);
++ assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 3, sender_name: 'cat' }), 'cat is not blocked by ann (only the other way round)');
++ assert.ok(notes.create({ user_id: 3, type: 'FOLLOW', sender_id: 2, sender_name: 'bob' }), 'cat did not block bob');
++ assert.ok(notes.create({ user_id: 1, type: 'WARNING', sender_id: 4, sender_name: 'mod', message: 'a warning' }), 'a staff action reaches ann although she blocked mod');
++ assert.strictEqual(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 4 }), null, 'but mod following her does not');
++ assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, actor_subject: null }), 'actor_subject null: no known person (a Live id in sender_id)');
++ assert.ok(notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');
++ assert.strictEqual(notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' }).length, 1, 'bulk: only cat hears bob went live');
++ await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
++ assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2 }), 'unblocked: notifications again');
++
++ // ── Import of Chat's exported dm_blocks ──
++ const importer = require('../scripts/import-blocks');
++ const file = path.join(dir, 'pairs.json');
++ fs.writeFileSync(file, JSON.stringify({ pairs: [
++ { blocker_subject: BOB, blocked_subject: CAT },
++ { blocker_subject: BOB, blocked_subject: CAT }, // duplicate
++ { blocker_subject: ANN, blocked_subject: BOB }, // ann unblocked bob on Network since: left alone
++ { blocker_subject: CAT, blocked_subject: CAT }, // self
++ { blocker_subject: CAT, blocked_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' }, // unknown account
++ { blocker_subject: '42', blocked_subject: BOB }, // not a subject
++ ] }));
++ const out = [];
++ let code = await importer.main(['--db', dbFile, '--file', file], (l) => out.push(l));
++ assert.strictEqual(code, 0);
++ assert.match(out.join('\n'), /pairs {6}6; to import 1; skipped: duplicate-in-file 1, already-on-network 1, self 1, unknown-account 1, not-a-subject 1/);
++ assert.ok(!blocks.isBlocked(db, BOB, CAT), 'dry run changed nothing');
++ const n = events().length;
++ code = await importer.main(['--db', dbFile, '--file', file, '--apply'], () => {});
++ assert.strictEqual(code, 0);
++ assert.ok(blocks.isBlocked(db, BOB, CAT), 'imported');
++ assert.ok(!blocks.isBlocked(db, ANN, BOB), 'an unblock on Network wins over the old Chat block');
++ assert.strictEqual(events().length, n + 1);
++ const imported = events().at(-1); valid(imported);
++ assert.deepStrictEqual([imported.payload.blocker, imported.payload.blocked, imported.payload.active, imported.payload.revision], [BOB, CAT, true, 1]);
++ out.length = 0;
++ await importer.main(['--db', dbFile, '--file', file], (l) => out.push(l));
++ assert.match(out.join('\n'), /to import 0/, 'a second run imports nothing');
++
++ // The account page lists blocks as text (DOM nodes), with unblock buttons.
++ const page = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
++ assert.ok(page.includes('id="blocks-list"') && page.includes('async function loadBlocks()') && page.includes("'/api/v1/me/blocks/' + encodeURIComponent(b.subject), { method: 'DELETE' }"));
++ const fn = page.slice(page.indexOf('async function loadBlocks()'), page.indexOf('async function revokeSession('));
++ assert.ok(!fn.includes('innerHTML'), 'no innerHTML in the blocks list');
++
++ server.close(); db.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ console.log('blocks: all checks passed');
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex bf4a5424..7b96a14c 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -121,7 +121,7 @@ const server = http.createServer(app);
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
-- assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.modules.read', 'network.modules.write'], 'resolve, plus its community.profile module');
++ assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, platform blocks, plus its community.profile module');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });