openvibers/openvibe.chat Commits

Remove account merge handling (#499)

OP OpenVibers committed 3 days ago · 597d14c4fa · 6 files, +10 −203

* Work in progress: remove account merge (unfinished)

Saved from a runner job that stopped before it finished (cx:account-merge-removal-cx2,
2026-11-10). The next job continues from this branch.

* Merge remote-tracking branch 'origin/main' into remove/account-merge-chat

* Remove account merge handling

Changes

ddiff --git a/README.md b/README.md
iindex c4dc8d7..328ebec 100644
---- a/README.md
++++ b/README.md
@@@ -886,7 +886,7 @@ Events: `chat.message.created`, `chat.message.deleted`, `chat.dm.created`, `chat
`chat.call.*`, `chat.tts.queued|played|failed`. Consumed at `POST /internal/events` (signature v2,
`CHAT_EVENTS_SECRET`, openvibe-sdk inbox): `live.release.deployed`, `network.module.updated`,
`network.user.token_valid_after`, `vip.membership.changed`, `network.block.changed`,
--`network.subject.merged`, `network.account.export_requested` and `network.account.deleted` (Chat's own
++`network.account.export_requested` and `network.account.deleted` (Chat's own
subscriptions, created at boot when missing).
Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](server/net/service-auth.js)):
ddiff --git a/STATUS.json b/STATUS.json
iindex 34e66b9..8bf281f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -46,7 +46,7 @@
"chat.room.message.created",
"chat.room.message.deleted"
],
-- "eventsConsumed": "live.release.deployed, network.module.updated, network.user.token_valid_after, vip.membership.changed, network.block.changed, network.subject.merged, network.account.export_requested and network.account.deleted at POST /internal/events (signature v2, openvibe-sdk inbox); subscriptions created at boot when missing (CHAT_EVENTS_SECRET and the Network grant chat events.subscription.manage openvibe.events)",
++ "eventsConsumed": "live.release.deployed, network.module.updated, network.user.token_valid_after, vip.membership.changed, network.block.changed, network.account.export_requested and network.account.deleted at POST /internal/events (signature v2, openvibe-sdk inbox); subscriptions created at boot when missing (CHAT_EVENTS_SECRET and the Network grant chat events.subscription.manage openvibe.events)",
"liveAuthorityFlag": "CHAT_AUTHORITY=chat is required in every mode since the cutover (2026-09-23 02:03 UTC): Live mounts no local chat routes and there is no rollback to Live; Live reads Chat's chat tables through Chat's internal read API (the read mirror was retired on 2026-10-05, so the cutover rollback through it no longer exists)",
"vip": "Member badges from OpenVibe.VIP (server/vip/badges.js: entitlements/check with product chat, cached 60 s, never blocks sending, fails closed; vip.membership.changed drops the cache). Needs the Network grant chat vip.entitlement.check openvibe.vip",
"preferences": "Chat preferences in the Network user module chat.preferences, plus chat.tts_defaults, chat.dm_settings and chat.presence_prefs (server/prefs/: /api/chat/preferences and siblings with Chat's service token, cached 60 s, If-Match on every write). Needs the grants chat network.modules.read/write",
@@@ -63,7 +63,7 @@
"openvibe.chat site: global chat, messages, rooms, settings, history download",
"chat preferences in Network user modules",
"VIP subscriber badges",
-- "network blocks, revocation, account merge, export and deletion from Network events",
++ "network blocks, revocation, export and deletion from Network events",
"openvibe-sdk service_outbox events and chat.room.message.* on the realtime plane",
"N-1 compatibility tests",
"per-actor limits (openvibe-sdk/limits)",
ddiff --git a/server/chat/subject-merge.js b/server/chat/subject-merge.js
ddeleted file mode 100644
iindex 8523a43..0000000
---- a/server/chat/subject-merge.js
++++ /dev/null
@@@ -1,125 +0,0 @@
--'use strict';
--/**
-- * network.subject.merged → Chat (roadmap WS-B task 5, ADR-029; Contracts 0.69.0). Two Network accounts became one:
-- * `from` is an alias of `into`. Chat keys people by subject and by Live user id (its ctx_users mirror maps the two);
-- * in one transaction it moves the folded-in account's
-- * messages chat_messages (author, and the channel it was posted in), room messages, DM messages
-- * DMs and rooms DM participants and room members (where the survivor is already in, the other row goes),
-- * DM conversations it started, rooms it owns, attachments it added, calls
-- * blocks DM blocks and the platform-block projection (the survivor's pair wins; blocking oneself goes)
-- * to the survivor. Moderation actions and AI summaries stay as they were recorded. Once per event (the consumer's
-- * inbox); a second run finds nothing under `from`.
-- */
--const db = require('../db/database');
--
--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
--
--// Static statements per table (plan T3 decision 7: no interpolated identifiers). @to/@from: a subject or a Live id.
--const BY_SUBJECT_MESSAGES = [
-- 'UPDATE chat_messages SET subject_id = @to WHERE subject_id = @from',
-- 'UPDATE room_messages SET subject_id = @to WHERE subject_id = @from',
-- 'UPDATE dm_messages SET sender_subject_id = @to WHERE sender_subject_id = @from',
--];
--const BY_USER = [
-- 'UPDATE chat_messages SET user_id = @to WHERE user_id = @from',
-- 'UPDATE chat_messages SET channel_user_id = @to WHERE channel_user_id = @from',
-- 'UPDATE room_messages SET user_id = @to WHERE user_id = @from',
-- 'UPDATE dm_messages SET sender_id = @to WHERE sender_id = @from',
-- 'UPDATE dm_conversations SET created_by = @to WHERE created_by = @from',
--];
--// Membership: (group, user) is one row; the survivor's stays.
--const MEMBERSHIP = [
-- {
-- counter: 'dm',
-- groups: 'SELECT conversation_id AS g FROM dm_participants WHERE user_id = ?',
-- has: 'SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?',
-- drop: 'DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?',
-- move: 'UPDATE dm_participants SET user_id = ? WHERE conversation_id = ? AND user_id = ?',
-- subject: 'UPDATE dm_participants SET subject_id = @to WHERE subject_id = @from',
-- },
-- {
-- counter: 'rooms',
-- groups: 'SELECT room_id AS g FROM room_members WHERE user_id = ?',
-- has: 'SELECT 1 FROM room_members WHERE room_id = ? AND user_id = ?',
-- drop: 'DELETE FROM room_members WHERE room_id = ? AND user_id = ?',
-- move: 'UPDATE room_members SET user_id = ? WHERE room_id = ? AND user_id = ?',
-- subject: null,
-- },
--];
--const BY_SUBJECT_OTHER = [
-- 'UPDATE rooms SET owner_subject = @to WHERE owner_subject = @from',
-- 'UPDATE room_attachments SET attached_by_subject = @to WHERE attached_by_subject = @from',
-- 'UPDATE calls SET created_by_subject = @to WHERE created_by_subject = @from',
-- 'UPDATE calls SET target_subject = @to WHERE target_subject = @from',
--];
--// Blocks, from either side: the survivor's pair wins, blocking oneself goes.
--const DM_BLOCKS = [
-- { rows: 'SELECT id, blocked_id AS o FROM dm_blocks WHERE blocker_id = ?', exists: 'SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?', move: 'UPDATE dm_blocks SET blocker_id = ? WHERE id = ?' },
-- { rows: 'SELECT id, blocker_id AS o FROM dm_blocks WHERE blocked_id = ?', exists: 'SELECT 1 FROM dm_blocks WHERE blocked_id = ? AND blocker_id = ?', move: 'UPDATE dm_blocks SET blocked_id = ? WHERE id = ?' },
--];
--const NETWORK_BLOCKS = [
-- {
-- rows: 'SELECT blocked_subject AS o FROM network_blocks WHERE blocker_subject = ?',
-- exists: 'SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ?',
-- drop: 'DELETE FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ?',
-- move: 'UPDATE network_blocks SET blocker_subject = ? WHERE blocker_subject = ? AND blocked_subject = ?',
-- },
-- {
-- rows: 'SELECT blocker_subject AS o FROM network_blocks WHERE blocked_subject = ?',
-- exists: 'SELECT 1 FROM network_blocks WHERE blocked_subject = ? AND blocker_subject = ?',
-- drop: 'DELETE FROM network_blocks WHERE blocked_subject = ? AND blocker_subject = ?',
-- move: 'UPDATE network_blocks SET blocked_subject = ? WHERE blocked_subject = ? AND blocker_subject = ?',
-- },
--];
--
--/** The payload { merge_id, from, into }, or null. */
--function payloadOf(event) {
-- const p = event && event.payload && typeof event.payload === 'object' ? event.payload : {};
-- if (!MERGE_RE.test(String(p.merge_id || '')) || !SUBJECT_RE.test(String(p.from || '')) || !SUBJECT_RE.test(String(p.into || '')) || p.from === p.into) return null;
-- return { merge_id: p.merge_id, from: p.from, into: p.into };
--}
--
--async function apply({ from, into, merge_id: mergeId }, { log = console } = {}) {
-- const d = db.getDb();
-- const idOf = async (s) => { const r = await d.prepare('SELECT id FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1').get(s); return r ? r.id : null; };
-- const a = await idOf(from); const b = await idOf(into);
-- const both = a != null && b != null && a !== b;
-- const c = { messages: 0, dm: 0, rooms: 0, dropped: 0 };
-- await d.tx(async () => {
-- const set = async (text, to, fromVal) => (await d.prepare(text).run({ to, from: fromVal })).changes;
-- // Messages: by subject, and by Live user id when both accounts have one.
-- for (const q of BY_SUBJECT_MESSAGES) c.messages += await set(q, into, from);
-- if (both) for (const q of BY_USER) await set(q, b, a);
-- for (const m of MEMBERSHIP) {
-- if (both) {
-- for (const r of await d.prepare(m.groups).all(a)) {
-- if (await d.prepare(m.has).get(r.g, b)) { await d.prepare(m.drop).run(r.g, a); c.dropped++; }
-- else { await d.prepare(m.move).run(b, r.g, a); c[m.counter]++; }
-- }
-- }
-- if (m.subject) await set(m.subject, into, from);
-- }
-- for (const q of BY_SUBJECT_OTHER) await set(q, into, from);
-- // DM blocks (by Live user id, unique per pair) and the platform-block projection (by subject).
-- if (both) {
-- for (const q of DM_BLOCKS) {
-- for (const r of await d.prepare(q.rows).all(a)) {
-- if (r.o === b || await d.prepare(q.exists).get(b, r.o)) { await d.prepare('DELETE FROM dm_blocks WHERE id = ?').run(r.id); c.dropped++; }
-- else await d.prepare(q.move).run(b, r.id);
-- }
-- }
-- }
-- await set('UPDATE dm_blocks SET blocker_subject_id = @to WHERE blocker_subject_id = @from', into, from);
-- for (const q of NETWORK_BLOCKS) {
-- for (const r of await d.prepare(q.rows).all(from)) {
-- if (r.o === into || await d.prepare(q.exists).get(into, r.o)) await d.prepare(q.drop).run(from, r.o);
-- else await d.prepare(q.move).run(into, from, r.o);
-- }
-- }
-- });
-- log.log(`[Merge] ${mergeId}: ${JSON.stringify({ live_users: [a, b], ...c })}`);
-- return 'merged';
--}
--
--module.exports = { payloadOf, apply };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 30441f1..756f16c 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -47,7 +47,7 @@ const ctx = require('../live-context');
const CONSUMER = 'chat';
const INBOX_TABLE = 'chat_event_inbox';
--const TOPICS = Object.freeze(['live.release.deployed', 'network.module.updated', 'network.user.token_valid_after', 'vip.membership.changed', 'network.block.changed', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted']);
++const TOPICS = Object.freeze(['live.release.deployed', 'network.module.updated', 'network.user.token_valid_after', 'vip.membership.changed', 'network.block.changed', 'network.account.export_requested', 'network.account.deleted']);
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 days: nothing older can be redelivered
@@@ -112,15 +112,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
if (!p) return 'ignored:payload';
return async () => (await networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');
}
-- if (event.event_type === 'network.subject.merged') {
-- // Two accounts became one (ADR-029): the folded-in account's messages, DMs, rooms and blocks are the survivor's.
-- if (event.source !== 'network') return 'ignored:source';
-- const merge = require('../chat/subject-merge');
-- const p = merge.payloadOf(event);
-- if (!p) return 'ignored:payload';
-- return async () => await merge.apply(p);
-- }
-- if (event.event_type === 'vip.membership.changed') {
++ if (event.event_type === 'vip.membership.changed') {
// A membership started, lapsed or was revoked: drop that member's cached badge answers for
// that creator now instead of waiting out the cache TTL (VIP convergence).
if (event.source !== 'vip') return 'ignored:source';
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 8972f1e..4361e51 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -125,8 +125,8 @@ t('boot with the consumer on and a stub Events', async () => {
t('boot created one subscription per topic, to /internal/events, with the consumer’s secret', async () => {
const results = await h.subscriptions.done;
-- assert.deepStrictEqual(results.map((r) => [r.topic, r.result]), [['live.release.deployed', 'created'], ['network.module.updated', 'created'], ['network.user.token_valid_after', 'created'], ['vip.membership.changed', 'created'], ['network.block.changed', 'created'], ['network.subject.merged', 'created'], ['network.account.export_requested', 'created'], ['network.account.deleted', 'created']]);
-- assert.strictEqual(stubEvents.subs.length, 8);
++ assert.deepStrictEqual(results.map((r) => [r.topic, r.result]), [['live.release.deployed', 'created'], ['network.module.updated', 'created'], ['network.user.token_valid_after', 'created'], ['vip.membership.changed', 'created'], ['network.block.changed', 'created'], ['network.account.export_requested', 'created'], ['network.account.deleted', 'created']]);
++ assert.strictEqual(stubEvents.subs.length, 7);
for (const s of stubEvents.subs) {
assert.strictEqual(s.endpoint, `http://127.0.0.1:${h.port}/internal/events`);
assert.strictEqual(s.secret, SECRET, 'the first CHAT_EVENTS_SECRET is handed to Events');
@@@ -141,17 +141,17 @@ t('a second boot changes nothing, and never re-enables a subscription an operato
const endpoint = subs.endpointFor(config, h.port);
const posts = stubEvents.posts;
let r = await subs.ensure({ config, endpoint });
-- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists', 'exists', 'exists', 'exists']);
++ assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists', 'exists', 'exists']);
assert.strictEqual(stubEvents.posts, posts, 'nothing was created again');
r = await subs.ensure({ config, endpoint, action: 'disable', topics: ['live.release.deployed'] });
assert.deepStrictEqual(r.map((x) => [x.result, x.enabled]), [['disabled', false]]);
r = await subs.ensure({ config, endpoint });
-- assert.deepStrictEqual(r.map((x) => [x.topic, x.result, x.enabled]), [['live.release.deployed', 'exists', false], ['network.module.updated', 'exists', true], ['network.user.token_valid_after', 'exists', true], ['vip.membership.changed', 'exists', true], ['network.block.changed', 'exists', true], ['network.subject.merged', 'exists', true], ['network.account.export_requested', 'exists', true], ['network.account.deleted', 'exists', true]]);
++ assert.deepStrictEqual(r.map((x) => [x.topic, x.result, x.enabled]), [['live.release.deployed', 'exists', false], ['network.module.updated', 'exists', true], ['network.user.token_valid_after', 'exists', true], ['vip.membership.changed', 'exists', true], ['network.block.changed', 'exists', true], ['network.account.export_requested', 'exists', true], ['network.account.deleted', 'exists', true]]);
assert.strictEqual(stubEvents.subs[0].enabled, false, 'still disabled');
r = await subs.ensure({ config, endpoint, action: 'enable', topics: ['live.release.deployed'] });
assert.strictEqual(stubEvents.subs[0].enabled, true);
r = await subs.ensure({ config, endpoint, action: 'list' });
-- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists', 'exists', 'exists', 'exists']);
++ assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists', 'exists', 'exists']);
});
t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigned → 401', async () => {
ddiff --git a/test/subject-merge.test.js b/test/subject-merge.test.js
ddeleted file mode 100644
iindex 62b5e13..0000000
---- a/test/subject-merge.test.js
++++ /dev/null
@@@ -1,60 +0,0 @@
--'use strict';
--/**
-- * network.subject.merged in Chat (roadmap WS-B task 5, ADR-029): the folded-in account's messages (by subject and by
-- * Live user id, and the channel it posted in), DM messages, DM memberships (a conversation both are in keeps the
-- * survivor's row), DM blocks and platform blocks (the survivor's pair wins; blocking oneself goes) become the
-- * survivor's; moderation history stays. Once per event (the inbox); another source or a bad payload is ignored.
-- */
--const assert = require('assert');
--const { ids } = require('openvibe-contracts');
--const { boot, suite } = require('./helpers');
--const t = suite('subject-merge');
--let h, keep, fold, other;
--const merged = (from, into, { source = 'network', mergeId = `mrg_${ids.ulid()}` } = {}) => ({
-- event_id: ids.newId('event'), event_type: 'network.subject.merged', version: 1, source,
-- actor: { type: 'user', id: into }, timestamp: new Date().toISOString(), visibility: 'internal',
-- subject: { type: 'user', id: into }, payload: { merge_id: mergeId, from, into, merged_at: new Date().toISOString(), initiated_by: 'person' },
--});
--
--t('boot', async () => {
-- h = await boot();
-- keep = h.addUser('keep', { subject: ids.newId('user') });
-- fold = h.addUser('fold', { subject: ids.newId('user') });
-- other = h.addUser('otto', { subject: ids.newId('user') });
-- await h.ctx.sync();
-- assert.ok(require('../server/events/consumer').TOPICS.includes('network.subject.merged'), 'Chat subscribes to merges');
--});
--
--t('the folded-in account becomes the survivor in Chat', async () => {
-- const d = require('../server/db/database').getDb();
-- // Messages: in otto's channel and in fold's own channel.
-- await d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'fold', 'hi otto', ?, ?), (?, 'otto', 'hi fold', ?, ?)")
-- .run(fold.id, other.id, fold.subject_id, other.id, fold.id, other.subject_id);
-- // DMs: fold↔otto (only fold), and a group where both keep and fold are.
-- const dmFO = (await h.http('POST', '/api/dm/conversations', { token: fold.token, body: { user_ids: [other.id] } })).body.conversation.id;
-- const group = (await h.http('POST', '/api/dm/conversations', { token: other.token, body: { user_ids: [keep.id, fold.id], name: 'Trio' } })).body.conversation.id;
-- assert.strictEqual((await h.http('POST', `/api/dm/conversations/${dmFO}/messages`, { token: fold.token, body: { message: 'yo' } })).status < 300, true);
-- await d.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?), (?, ?, ?)').run(fold.id, other.id, fold.subject_id, fold.id, keep.id, fold.subject_id);
-- require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();
-- await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(fold.subject_id, other.subject_id, fold.subject_id, keep.subject_id);
-- await d.prepare("INSERT INTO moderation_actions (action_type, actor_subject_id) VALUES ('timeout', ?)").run(fold.subject_id);
--
-- assert.strictEqual((await h.eventsConsumer.apply(merged(fold.subject_id, keep.subject_id, { source: 'live' }))).outcome, 'ignored:source');
-- assert.strictEqual((await h.eventsConsumer.apply({ ...merged(fold.subject_id, keep.subject_id), payload: { merge_id: 'nope' } })).outcome, 'ignored:payload');
-- const ev = merged(fold.subject_id, keep.subject_id);
-- assert.strictEqual((await h.eventsConsumer.apply(ev)).outcome, 'merged');
--
-- const q = async (sql, ...a) => await d.prepare(sql).all(...a);
-- assert.deepStrictEqual((await q('SELECT user_id AS u, channel_user_id AS c, subject_id AS s FROM chat_messages ORDER BY id')).map((m) => [m.u, m.c, m.s]),
-- [[keep.id, other.id, keep.subject_id], [other.id, keep.id, other.subject_id]], 'authorship and the channel follow');
-- assert.ok((await q('SELECT sender_id, sender_subject_id FROM dm_messages')).every((m) => m.sender_id === keep.id && m.sender_subject_id === keep.subject_id));
-- assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', dmFO)).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y));
-- assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', group)).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y), 'in the group once');
-- assert.deepStrictEqual((await q('SELECT blocker_id AS a, blocked_id AS b FROM dm_blocks')).map((x) => [x.a, x.b]), [[keep.id, other.id]], 'moved; blocking oneself gone');
-- assert.deepStrictEqual((await q('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks')).map((x) => [x.a, x.b]), [[keep.subject_id, other.subject_id]]);
-- assert.deepStrictEqual((await q('SELECT actor_subject_id AS a FROM moderation_actions')).map((x) => x.a), [fold.subject_id], 'moderation history stays as recorded');
-- // The inbox makes a redelivery a no-op.
-- assert.strictEqual((await h.eventsConsumer.apply(ev)).duplicate, true);
--});
--
--t.run(async () => { if (h && h.close) await h.close(); });