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
Changes
@@@ -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 SwitchingGoogle-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 |---@@@ -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",@@@ -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",@@@ -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); }nnew file mode 100644@@@ -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 };@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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', []],@@@ -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).@@@ -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" notificationsconst allLiveUserIds = streamLive.allLiveSubscribers(db);@@@ -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 notificationsWHERE user_id = ? AND is_dismissed = 0@@@ -133,6 +148,9 @@ class NotificationService {// Check user's preferences — skip if disabledconst 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).*/nnew file mode 100644@@@ -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); });@@@ -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}` });
@@ -350,6 +350,26 @@ Namespaces, schemas, writers, public fields and quotas come from openvibe-contra
@@ -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
## Multi-Account Switching
Google-style account management supporting up to 5 accounts:
Google-style account management supporting up to 5 accounts:
@@ -550,7 +570,8 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
@@ -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 |
| `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) |
| `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 |
| `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 |
| `analytics_visitor_days`, `analytics_day_salts` | The current day's salted visitor hashes and salt, deleted after the day's final rollup |
---
---
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -501,6 +501,16 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -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>
<button class="btn" onclick="signOutEverywhere()"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere, this device too</button>
</div>
</div>
</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>
</div>
<!-- Anon Info Section -->
<!-- Anon Info Section -->
@@ -1117,6 +1127,7 @@ async function loadUser() {
@@ -1117,6 +1127,7 @@ async function loadUser() {
loadNotifPrefs();
loadNotifPrefs();
loadLinked();
loadLinked();
loadSessions();
loadSessions();
+ loadBlocks();
loadAnonInfo();
loadAnonInfo();
loadEmailStatus();
loadEmailStatus();
loadRecentTools();
loadRecentTools();
@@ -1507,6 +1518,60 @@ async function loadSessions() {
@@ -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>'; }
} 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) {
async function revokeSession(id) {
try { await apiFetch('/api/auth/sessions/' + id, { method: 'DELETE' }); loadSessions(); }
try { await apiFetch('/api/auth/sessions/' + id, { method: 'DELETE' }); loadSessions(); }
catch (err) { alert('Error: ' + err.message); }
catch (err) { alert('Error: ' + err.message); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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 };
@@ -741,6 +741,8 @@ function initDb(dbPath) {
@@ -741,6 +741,8 @@ function initDb(dbPath) {
require('../identity/modules').ensureSchema(db);
require('../identity/modules').ensureSchema(db);
// Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).
// Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).
require('../developer/store').ensureSchema(db);
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');
console.log('[DB] Central database initialized');
return db;
return db;
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -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 };
@@ -168,6 +168,10 @@ const DEFAULT_GRANTS = [
@@ -168,6 +168,10 @@ const DEFAULT_GRANTS = [
// Chat consumes live.release.deployed (the deploy card, register C-84) and network.module.updated
// 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.
// (its chat.preferences cache) through its own Events subscriptions, created at Chat's boot.
['chat', 'events.subscription.manage', 'openvibe.events', []],
['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.live_bridge.write', 'openvibe.chat', []],
['live', 'chat.presence.read', 'openvibe.chat', []],
['live', 'chat.presence.read', 'openvibe.chat', []],
['live', 'chat.message.send', 'openvibe.chat', []],
['live', 'chat.message.send', 'openvibe.chat', []],
@@ -397,6 +397,10 @@ app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }),
@@ -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.
// 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.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));
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 ───────────────────────────────────────────────────
// ── Routes ───────────────────────────────────────────────────
// Public key endpoint (services fetch this to verify JWTs).
// Public key endpoint (services fetch this to verify JWTs).
@@ -417,7 +417,9 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
@@ -417,7 +417,9 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
try {
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;
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;
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
// Find users who opted into "all live" notifications
const allLiveUserIds = streamLive.allLiveSubscribers(db);
const allLiveUserIds = streamLive.allLiveSubscribers(db);
@@ -9,6 +9,9 @@
@@ -9,6 +9,9 @@
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');
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 {
class NotificationService {
constructor(db) {
constructor(db) {
this.db = db;
this.db = db;
@@ -108,6 +111,18 @@ class NotificationService {
@@ -108,6 +111,18 @@ class NotificationService {
SELECT COUNT(*) AS c FROM email_delivery_log WHERE status = 'sent' AND created_at > datetime('now', '-1 day')
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(`
this._newestForUser = db.prepare(`
SELECT * FROM notifications
SELECT * FROM notifications
WHERE user_id = ? AND is_dismissed = 0
WHERE user_id = ? AND is_dismissed = 0
@@ -133,6 +148,9 @@ class NotificationService {
@@ -133,6 +148,9 @@ class NotificationService {
// Check user's preferences — skip if disabled
// Check user's preferences — skip if disabled
const pref = this._getPrefByCategory.get(data.user_id, category);
const pref = this._getPrefByCategory.get(data.user_id, category);
if (pref && !pref.enabled) return null;
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).
// Go-live dedupe (see _recentFromSender).
if (data.type === 'STREAM_LIVE' && data.sender_id != null) {
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 { /* */ }
try { if (this._recentFromSender.get(data.user_id, 'STREAM_LIVE', data.sender_id)) return null; } catch { /* */ }
@@ -163,6 +181,20 @@ class NotificationService {
@@ -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() };
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).
* Bulk-create notifications for multiple users (e.g., broadcast).
*/
*/
new file mode 100644
new file mode 100644
@@ -0,0 +1,217 @@
@@ -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); });
@@ -121,7 +121,7 @@ const server = http.createServer(app);
@@ -121,7 +121,7 @@ const server = http.createServer(app);
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
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' });
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
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}` });
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');
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}` });
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });