openvibers/openvibe.community Commits

Community takes part in account export and deletion (roadmap WS-B task 7, ADR-033; openvibe-contracts 0.49.0 -> 0.71.0). The pulse consumer takes network.account.export_requested and network.account.deleted (subscribed at boot). server/identity/account-data.js applies each once per export or deletion (account_data_events) and answers after Network took the part or confirmation, so a failure is redelivered without erasing twice.

OP OpenVibers committed 27 Sept 2026 · 82e2bb941f · 7 files, +330 −8

Export: Community's part goes to Network with a service token: pastes with their content, paste comments, comments, threads, posts, attachments, likes, votes, reactions, game progress, blocks and activity.

Deletion: what the subject and its merged-in aliases wrote goes. An item with someone else's reply anywhere beneath it (a recursive check) stays as an authorless "[deleted]" tombstone, so the replies keep their place: a commented paste, an answered comment, a thread others posted in and its opening post. Likes, votes and reactions go, with like counts, scores and reply counts recomputed. Game progress, blocks both ways, activity items and the cached profile go. Spaces the person created stay without them. Community then confirms with counts.

test/account-data.test.js covers the export, each rule of the erasure, aliases, the confirmation and its retry, and redelivery. The SSRF inventory classifies the new Network calls. npm test 48/48.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 4a93c61..3ac35a0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
@@@ -1057,9 +1057,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.71.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
++ "integrity": "sha512-BLS6gP/MKuwhvTdWO8UYUaOyhmf7ni0cQ8r7150o57R5TrCnzdWQEVESxfMFCGgSbm2wdKtdZwTID69nxD14Tg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 24b02b4..fffd04c 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
ddiff --git a/server/app.js b/server/app.js
iindex da266bd..433af5e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -206,7 +206,7 @@ function createApp(opts = {}) {
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter }));
app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
-- const pulseConsumer = require('./pulse/consumer').createPulseConsumer({ db, vipCache: vip && vip.cache, revocations, secrets: String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean) });
++ const pulseConsumer = require('./pulse/consumer').createPulseConsumer({ db, vipCache: vip && vip.cache, revocations, accountSend: config.oauth && config.oauth.clientSecret ? require('./identity/account-data').createSender({ config }) : null, secrets: String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean) });
app.locals.pulseConsumer = pulseConsumer;
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
nnew file mode 100644
iindex 0000000..41dde28
---- /dev/null
++++ b/server/identity/account-data.js
@@@ -0,0 +1,207 @@
++'use strict';
++/**
++ * Account export and deletion → Community (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at the pulse
++ * consumer (POST /internal/events) and are applied once per export or deletion (account_data_events); the delivery is
++ * answered after Network took the part or the confirmation, so a failure is redelivered without erasing twice.
++ *
++ * network.account.export_requested Community's part (POST /internal/account-exports/:id/parts with a service token):
++ * pastes (with their content), paste comments, comments, threads, posts,
++ * attachments, likes, votes and reactions, game progress, blocks and activity.
++ * network.account.deleted what the subject (and the accounts merged into it) wrote goes. An item with
++ * someone else's reply anywhere beneath it stays as an authorless tombstone
++ * ("[deleted]"), so the replies keep their place:
++ * - a paste others commented on;
++ * - a comment or paste comment others answered;
++ * - a thread others posted in, and its opening post.
++ * Likes, votes and reactions go and cached counts are recomputed. Game progress,
++ * blocks both ways, activity items and the cached profile go. Spaces the person
++ * created stay without them. Community then confirms with counts.
++ */
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TOPICS = ['network.account.export_requested', 'network.account.deleted'];
++const TOMBSTONE = '[deleted]';
++const ROW_LIMIT = 5000;
++
++const hasTable = (db, t) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);
++const hasColumn = (db, table, col) => { try { return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col); } catch { return false; } };
++const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
++
++function ensureSchema(db) {
++ db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
++ id TEXT PRIMARY KEY,
++ kind TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ outcome TEXT,
++ sent_at TEXT,
++ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
++ )`);
++}
++
++// ── Export ─────────────────────────────────────────────────────
++
++const EXPORTS = [
++ ['pastes.json', 'pastes', 'owner_subject'], ['paste_comments.json', 'paste_comments', 'author_subject'], ['comments.json', 'comments', 'author_subject'],
++ ['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],
++ ['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
++ ['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
++ ['activity.json', 'pulse_items', 'actor_subject'],
++];
++
++function exportPart(db, subject) {
++ const files = [];
++ const truncated = [];
++ for (const [name, table, col] of EXPORTS) {
++ if (!hasColumn(db, table, col)) continue;
++ const rows = db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);
++ if (!rows.length) continue;
++ if (rows.length > ROW_LIMIT) truncated.push(name);
++ files.push({ name, content: rows.slice(0, ROW_LIMIT) });
++ }
++ return { files, truncated, note: 'Paste screenshots and attachments are downloaded from their URLs.' };
++}
++
++// ── Deletion ───────────────────────────────────────────────────
++
++/** Ids of the rows in `table` authored by the subjects, split into those to delete and those with others' replies. */
++function splitByReplies(db, table, subjects) {
++ const mine = db.prepare(`SELECT id FROM ${table} WHERE author_subject IN ${inList(subjects)}`).all(...subjects).map((r) => r.id);
++ const keep = [];
++ const drop = [];
++ for (const id of mine) {
++ const other = db.prepare(`WITH RECURSIVE d(id) AS (SELECT id FROM ${table} WHERE parent_id = ? UNION ALL SELECT c.id FROM ${table} c JOIN d ON c.parent_id = d.id)
++ SELECT 1 FROM ${table} WHERE id IN (SELECT id FROM d) AND (author_subject IS NULL OR author_subject NOT IN ${inList(subjects)}) LIMIT 1`).get(id, ...subjects);
++ (other ? keep : drop).push(id);
++ }
++ return { keep, drop };
++}
++
++function erase(db, subjects, { now = new Date().toISOString() } = {}) {
++ const erased = {};
++ const retained = {};
++ const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
++ const S = inList(subjects);
++ db.transaction(() => {
++ // Likes, votes and reactions first, so the counts recomputed below see only what stays.
++ const recount = [];
++ for (const [table, item] of [['paste_likes', 'paste_id'], ['comment_votes', 'comment_id'], ['thread_votes', 'thread_id'], ['post_reactions', 'post_id']]) {
++ if (!hasTable(db, table)) continue;
++ const items = db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects).map((r) => r.i);
++ add(erased, table, db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects).changes);
++ recount.push([table, items]);
++ }
++ // Pastes: gone, unless someone else commented.
++ if (hasTable(db, 'pastes')) {
++ for (const p of db.prepare(`SELECT id FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {
++ const others = db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);
++ if (others) {
++ db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,
++ ai_summary = NULL, ai_tags = NULL, legacy_user_id = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
++ add(retained, 'tombstones', 1);
++ } else {
++ db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
++ add(erased, 'pastes', 1);
++ }
++ }
++ }
++ // Comments on pastes and on threads (VODs, clips, pages): gone, unless someone else answered beneath.
++ for (const [table, tomb] of [['paste_comments', 'author_subject = NULL, anon_name = NULL, message = ?, is_deleted = 1, updated_at = ?'],
++ ['comments', 'author_subject = NULL, anon_name = NULL, message = ?, deleted_at = COALESCE(deleted_at, ?)']]) {
++ if (!hasTable(db, table)) continue;
++ const { keep, drop } = splitByReplies(db, table, subjects);
++ for (const id of keep) db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);
++ for (const id of drop) db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
++ add(erased, table, drop.length);
++ add(retained, 'tombstones', keep.length);
++ if (table === 'comments') db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id) WHERE reply_count != (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id)').run();
++ }
++ // Threads: gone with their posts, unless someone else posted; then the thread and its opening post are tombstones.
++ if (hasTable(db, 'threads')) {
++ for (const t of db.prepare(`SELECT id FROM threads WHERE author_subject IN ${S}`).all(...subjects)) {
++ const others = db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(t.id, ...subjects);
++ if (others) { db.prepare('UPDATE threads SET author_subject = NULL, title = ? WHERE id = ?').run(TOMBSTONE, t.id); add(retained, 'tombstones', 1); }
++ else { db.prepare('DELETE FROM threads WHERE id = ?').run(t.id); add(erased, 'threads', 1); }
++ }
++ }
++ if (hasTable(db, 'posts')) {
++ for (const p of db.prepare(`SELECT id, thread_id, is_opening FROM posts WHERE author_subject IN ${S}`).all(...subjects)) {
++ const others = p.is_opening && db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND id != ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.thread_id, p.id, ...subjects);
++ if (others) { db.prepare('UPDATE posts SET author_subject = NULL, relay_author = NULL, body_markdown = ?, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?').run(TOMBSTONE, now, now, p.id); add(retained, 'tombstones', 1); }
++ else { db.prepare('DELETE FROM posts WHERE id = ?').run(p.id); add(erased, 'posts', 1); }
++ }
++ if (hasColumn(db, 'threads', 'reply_count')) db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
++ }
++ if (hasTable(db, 'attachments')) add(erased, 'attachments', db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects).changes);
++ for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
++ ['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
++ ['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {
++ if (!hasTable(db, table)) continue;
++ const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;
++ add(erased, key, db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes);
++ }
++ for (const table of ['spaces', 'comment_threads']) if (hasColumn(db, table, 'created_by')) db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);
++ // The cached counts and scores follow the rows that stay.
++ for (const [table, items] of recount) {
++ for (const i of items) {
++ if (table === 'paste_likes') db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(i, i);
++ else if (table === 'comment_votes') {
++ const a = db.prepare('SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS u, COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS d FROM comment_votes WHERE comment_id = ?').get(i);
++ db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, i);
++ } else if (table === 'thread_votes') db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0) FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(i, i);
++ }
++ }
++ })();
++ return { erased, retained };
++}
++
++// ── Events ─────────────────────────────────────────────────────
++
++function createSender({ config, fetchImpl = globalThis.fetch } = {}) {
++ const { serviceAuth } = require('openvibe-contracts');
++ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.network', fetchImpl });
++ return async function send(path, body, retried = false) {
++ const res = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
++ if (res.status === 401 && !retried) { tokens.invalidate(); return send(path, body, true); }
++ return res;
++ };
++}
++
++/** One envelope → 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>'; throws to be redelivered. */
++async function apply(db, ev, { send, log = console } = {}) {
++ if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';
++ if (ev.source !== 'network') return 'ignored:source';
++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
++ ensureSchema(db);
++ if (ev.event_type === 'network.account.export_requested') {
++ if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
++ const seen = db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);
++ if (seen && seen.sent_at) return 'unchanged';
++ const part = exportPart(db, p.subject);
++ const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });
++ const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
++ if (!outcome) throw new Error(`export part refused: ${res.status}`);
++ db.prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')
++ .run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());
++ return outcome;
++ }
++ if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
++ let rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
++ let result = 'confirmed';
++ if (!rec) {
++ const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
++ const counts = erase(db, subjects);
++ db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));
++ log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);
++ rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
++ result = 'erased';
++ }
++ if (rec.sent_at) return 'unchanged';
++ const o = JSON.parse(rec.outcome || '{}');
++ const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });
++ if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);
++ db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);
++ return result;
++}
++
++module.exports = { apply, exportPart, erase, ensureSchema, createSender, TOPICS };
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex f7a2241..8b77f36 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -37,7 +37,7 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
--const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.subject.merged']);
++const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted']);
const GAME_NAMESPACE = 'games.progress.summary';
const GAME_MILESTONE = 5;
const GAME_URL = 'https://openvibe.games/';
@@@ -100,7 +100,7 @@ function applyGameProgress(db, g) {
return r.created ? 'pulse:created' : 'pulse:updated';
}
--function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, now = () => Date.now(), log = console } = {}) {
++function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const inbox = createInbox(db, { table: 'community_event_inbox', now });
inbox.ensureSchema();
@@@ -137,6 +137,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
}
}
++ if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {
++ // Account export and deletion (ADR-033, ../identity/account-data.js): once per export or deletion by its own record,
++ // answered after Network took the part or confirmation, so a failure is redelivered without erasing twice.
++ if (!accountSend) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_client' }); }
++ return require('../identity/account-data').apply(db, event, { send: accountSend, log }).then((outcome) => {
++ stats.applied++;
++ res.json({ event_id: event.event_id, duplicate: outcome === 'unchanged', outcome });
++ }, (err) => {
++ stats.failed++;
++ log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
++ problem(500, 'community.event_failed', 'processing failed; it will be retried');
++ });
++ }
if (event.event_type === 'network.subject.merged') {
// Two accounts became one (ADR-029): the folded-in subject's rows become the survivor's (../identity/subject-merge.js).
const merge = require('../identity/subject-merge');
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..439aa33
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * Account export and deletion in Community (roadmap WS-B task 7, ADR-033): through the signed pulse consumer,
++ * network.account.export_requested sends Community's part to Network (the subject's pastes, comments, threads, posts,
++ * likes, votes, reactions, progress, blocks, activity), and network.account.deleted erases what the subject and its
++ * merged-in aliases wrote. What someone else answered beneath stays as an authorless "[deleted]" tombstone. Likes,
++ * votes and reactions go with the counts recomputed, and progress, blocks and activity go. Community confirms with
++ * counts; a failed confirmation is retried without erasing again, and a redelivery sends nothing twice.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { validate } = require('openvibe-contracts');
++const { openDb } = require('../server/db');
++const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
++const SECRET = `whsec_${'ab'.repeat(32)}`;
++const db = openDb(':memory:');
++const sent = [];
++let failNext = false;
++const accountSend = async (p, body) => { if (failNext) { failNext = false; return { ok: false, status: 503 }; } sent.push({ path: p, body }); return { ok: true, status: 200 }; };
++const app = express();
++app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], accountSend, log: { error() {}, log() {}, warn() {} } }).router);
++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1', OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2', OTTO = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
++
++db.prepare("INSERT INTO pastes (id, slug, owner_subject, title, content, likes) VALUES (1, 'p1', ?, 'mine, answered', 'secret words', 1), (2, 'p2', ?, 'mine, alone', 'more', 0), (3, 'p3', ?, 'otto''s', 'x', 1), (4, 'p4', ?, 'old account', 'y', 0)").run(DANA, DANA, OTTO, OLD);
++db.prepare("INSERT INTO paste_comments (id, paste_id, author_subject, message, parent_id) VALUES (1, 1, ?, 'nice', NULL), (2, 3, ?, 'hi otto', NULL), (3, 3, ?, 'hi dana', 2), (4, 3, ?, 'bye', NULL)").run(OTTO, DANA, OTTO, DANA);
++db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);
++db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) VALUES (1, 'live', 'vod', '9')").run();
++db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);
++db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);
++db.prepare("INSERT INTO spaces (id, slug, name, created_by) VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);
++db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);
++db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject, is_opening) VALUES (1, 1, 'opening', ?, 1), (2, 1, 'otto answers', ?, 0), (3, 2, 'quiet opening', ?, 1)").run(DANA, OTTO, DANA);
++db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (2, ?, 1)').run(OTTO);
++db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (2, ?, 'fire')").run(DANA);
++db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 4, '2026-09-27')").run(DANA, OTTO);
++require('../server/identity/blocks').ensureSchema(db);
++db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(DANA, OTTO, OTTO, DANA);
++
++const evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
++ subject: { type: 'user', id: payload.subject }, actor: { type: 'user', id: payload.subject }, payload });
++
++(async () => {
++ assert.ok(TOPICS.includes('network.account.export_requested') && TOPICS.includes('network.account.deleted'), 'Community subscribes to both');
++ const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
++ const post = (event) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } }).then(async (r) => ({ status: r.status, json: await r.json() }));
++ };
++ try {
++ // ── Export ──
++ const exp = evt('evt_01JAB2C3D4E5F6G7H8J9K0MN01', 'network.account.export_requested', { export_id: 'exp_01JAB2C3D4E5F6G7H8J9K0MNP4', subject: DANA, requested_at: new Date().toISOString(), deadline: new Date(Date.now() + 1800000).toISOString() });
++ let r = await post({ ...exp, source: 'live' });
++ assert.strictEqual(r.json.outcome, 'ignored:source');
++ r = await post(exp);
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
++ const part = sent[0].body;
++ assert.ok(validate('network.account-export-part@1', part).valid, JSON.stringify(validate('network.account-export-part@1', part).errors));
++ const files = Object.fromEntries(part.files.map((f) => [f.name, f.content]));
++ assert.deepStrictEqual(files['pastes.json'].map((p) => p.slug).sort(), ['p1', 'p2'], 'her pastes, with their content');
++ assert.strictEqual(files['pastes.json'].find((p) => p.slug === 'p1').content, 'secret words');
++ assert.ok(files['comments.json'] && files['posts.json'] && files['blocks.json'] && files['game_progress.json']);
++ r = await post(exp);
++ assert.strictEqual(r.json.outcome, 'unchanged');
++ assert.strictEqual(sent.length, 1);
++
++ // ── Deletion ──
++ const del = evt('evt_01JAB2C3D4E5F6G7H8J9K0MN02', 'network.account.deleted', { deletion_id: 'del_01JAB2C3D4E5F6G7H8J9K0MNP5', subject: DANA, aliases: [OLD], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });
++ failNext = true;
++ r = await post(del);
++ assert.strictEqual(r.status, 500, 'a failed confirmation is redelivered');
++ const all = (sql) => db.prepare(sql).all();
++ assert.deepStrictEqual(all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [
++ { slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");
++ assert.deepStrictEqual(all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [
++ { id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
++ assert.deepStrictEqual(all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
++ { id: 1, a: null, message: '[deleted]', score: 0, reply_count: 1 }, { id: 2, a: OTTO, message: 'reply', score: 0, reply_count: 0 }], 'votes gone and the score recomputed');
++ assert.deepStrictEqual(all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');
++ assert.deepStrictEqual(all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);
++ assert.strictEqual(all('SELECT * FROM post_reactions').length + all('SELECT * FROM paste_likes').length, 0);
++ assert.strictEqual(db.prepare('SELECT likes FROM pastes WHERE id = 3').get().likes, 0, 'the like count follows');
++ assert.deepStrictEqual(all('SELECT subject_id AS s FROM game_progress'), [{ s: OTTO }]);
++ assert.strictEqual(all('SELECT * FROM network_blocks').length, 0, 'blocks both ways');
++ assert.strictEqual(db.prepare('SELECT created_by FROM spaces WHERE id = 901').get().created_by, null, 'the space stays without her');
++
++ r = await post(del);
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'confirmed'], 'the retry confirms without erasing again');
++ const conf = sent[1];
++ assert.strictEqual(conf.path, '/internal/account-deletions/del_01JAB2C3D4E5F6G7H8J9K0MNP5/confirmations');
++ assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
++ assert.deepStrictEqual([conf.body.erased.pastes, conf.body.erased.threads, conf.body.retained.tombstones], [2, 1, 5]);
++ r = await post(del);
++ assert.strictEqual(r.json.outcome, 'unchanged');
++ assert.strictEqual(sent.length, 2);
++ } finally {
++ srv.close();
++ }
++ console.log('community account export and deletion: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 9067109..2d1caf7 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -29,6 +29,7 @@ const INVENTORY = {
'server/chat-rooms.js': [1, 'OV_CHAT_INTERNAL_URL /api/chat/rooms/<slug>/attachments; the slug must match ROOM_SLUG and is encoded'],
'server/events.js': [3, 'service token (Network) and the outbox publisher: EVENTS_URL'],
'server/identity/network.js': [2, 'Network /internal/identity/resolve-batch with a service token'],
++ 'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'server/live-client.js': [1, 'OV_LIVE_INTERNAL_URL/api + fixed paths; slugs encoded (callers check SLUG_RE)'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],