openvibers/openvibe.chat Commits
Remove account merge handling (#499)
Changes
@@@ -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 ownsubscriptions, created at boot when missing).Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](server/net/service-auth.js)):@@@ -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)",ddeleted file mode 100644@@@ -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 };@@@ -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';@@@ -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 operatoconst endpoint = subs.endpointFor(config, h.port);const posts = stubEvents.posts;let r = await subs.ensure({ config, endpoint });-- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', '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 () => {ddeleted file mode 100644@@@ -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(); });
@@ -886,7 +886,7 @@ Events: `chat.message.created`, `chat.message.deleted`, `chat.dm.created`, `chat
@@ -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.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`,
`CHAT_EVENTS_SECRET`, openvibe-sdk inbox): `live.release.deployed`, `network.module.updated`,
`network.user.token_valid_after`, `vip.membership.changed`, `network.block.changed`,
`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).
subscriptions, created at boot when missing).
Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](server/net/service-auth.js)):
Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](server/net/service-auth.js)):
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
"chat.room.message.created",
"chat.room.message.created",
"chat.room.message.deleted"
"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)",
"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",
"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",
"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 @@
@@ -63,7 +63,7 @@
"openvibe.chat site: global chat, messages, rooms, settings, history download",
"openvibe.chat site: global chat, messages, rooms, settings, history download",
"chat preferences in Network user modules",
"chat preferences in Network user modules",
"VIP subscriber badges",
"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",
"openvibe-sdk service_outbox events and chat.room.message.* on the realtime plane",
"N-1 compatibility tests",
"N-1 compatibility tests",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
deleted file mode 100644
deleted file mode 100644
@@ -1,125 +0,0 @@
@@ -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 };
@@ -47,7 +47,7 @@ const ctx = require('../live-context');
@@ -47,7 +47,7 @@ const ctx = require('../live-context');
const CONSUMER = 'chat';
const CONSUMER = 'chat';
const INBOX_TABLE = 'chat_event_inbox';
const INBOX_TABLE = 'chat_event_inbox';
-const TOPICS = Object.freeze(['live.release.deployed', 'network.module.updated', 'network.user.token_valid_after', 'vip.membership.changed', '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 SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 days: nothing older can be redelivered
const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 days: nothing older can be redelivered
@@ -112,15 +112,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -112,15 +112,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
if (!p) return 'ignored:payload';
if (!p) return 'ignored:payload';
return async () => (await networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');
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
// A membership started, lapsed or was revoked: drop that member's cached badge answers for
// that creator now instead of waiting out the cache TTL (VIP convergence).
// that creator now instead of waiting out the cache TTL (VIP convergence).
if (event.source !== 'vip') return 'ignored:source';
if (event.source !== 'vip') return 'ignored:source';
@@ -125,8 +125,8 @@ t('boot with the consumer on and a stub Events', async () => {
@@ -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 () => {
t('boot created one subscription per topic, to /internal/events, with the consumer’s secret', async () => {
const results = await h.subscriptions.done;
const results = await h.subscriptions.done;
- assert.deepStrictEqual(results.map((r) => [r.topic, r.result]), [['live.release.deployed', 'created'], ['network.module.updated', 'created'], ['network.user.token_valid_after', 'created'], ['vip.membership.changed', 'created'], ['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) {
for (const s of stubEvents.subs) {
assert.strictEqual(s.endpoint, `http://127.0.0.1:${h.port}/internal/events`);
assert.strictEqual(s.endpoint, `http://127.0.0.1:${h.port}/internal/events`);
assert.strictEqual(s.secret, SECRET, 'the first CHAT_EVENTS_SECRET is handed to Events');
assert.strictEqual(s.secret, SECRET, 'the first CHAT_EVENTS_SECRET is handed to Events');
@@ -141,17 +141,17 @@ t('a second boot changes nothing, and never re-enables a subscription an operato
@@ -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 endpoint = subs.endpointFor(config, h.port);
const posts = stubEvents.posts;
const posts = stubEvents.posts;
let r = await subs.ensure({ config, endpoint });
let r = await subs.ensure({ config, endpoint });
- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', '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');
assert.strictEqual(stubEvents.posts, posts, 'nothing was created again');
r = await subs.ensure({ config, endpoint, action: 'disable', topics: ['live.release.deployed'] });
r = await subs.ensure({ config, endpoint, action: 'disable', topics: ['live.release.deployed'] });
assert.deepStrictEqual(r.map((x) => [x.result, x.enabled]), [['disabled', false]]);
assert.deepStrictEqual(r.map((x) => [x.result, x.enabled]), [['disabled', false]]);
r = await subs.ensure({ config, endpoint });
r = await subs.ensure({ config, endpoint });
- assert.deepStrictEqual(r.map((x) => [x.topic, x.result, x.enabled]), [['live.release.deployed', 'exists', false], ['network.module.updated', 'exists', true], ['network.user.token_valid_after', 'exists', true], ['vip.membership.changed', 'exists', true], ['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');
assert.strictEqual(stubEvents.subs[0].enabled, false, 'still disabled');
r = await subs.ensure({ config, endpoint, action: 'enable', topics: ['live.release.deployed'] });
r = await subs.ensure({ config, endpoint, action: 'enable', topics: ['live.release.deployed'] });
assert.strictEqual(stubEvents.subs[0].enabled, true);
assert.strictEqual(stubEvents.subs[0].enabled, true);
r = await subs.ensure({ config, endpoint, action: 'list' });
r = await subs.ensure({ config, endpoint, action: 'list' });
- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', '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 () => {
t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigned → 401', async () => {
deleted file mode 100644
deleted file mode 100644
@@ -1,60 +0,0 @@
@@ -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(); });