From d393a870c228e1c18a9a6e1f943a66f6374f4582 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:13:48 +0200 Subject: [PATCH] feat(federation): dmOriginFailover utility (rekey + failover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit failoverDmOriginsFromDisconnected(origin) walks pinned DMs and re-keys them to a connected sibling origin's local channel id (via dmAlternatives federatedId lookup). Preference: home first, then any connected remote in insertion order. rekeyDmChannel performs the atomic rename across spaceStore (dmChannels / channelOriginMap / channelLastMessageIds / dmAlternatives), chatStore (via rekeyChannelState), and the URL (via history.replaceState when viewing the rekeyed DM). Voice state is intentionally untouched — LiveKit sessions can't migrate across origins. Old origin's local id is retained in dmAlternatives for possible later fail-back without another ready round-trip. --- .../web/src/utils/dmOriginFailover.test.ts | 314 ++++++++++++++++++ packages/web/src/utils/dmOriginFailover.ts | 127 +++++++ 2 files changed, 441 insertions(+) create mode 100644 packages/web/src/utils/dmOriginFailover.test.ts create mode 100644 packages/web/src/utils/dmOriginFailover.ts diff --git a/packages/web/src/utils/dmOriginFailover.test.ts b/packages/web/src/utils/dmOriginFailover.test.ts new file mode 100644 index 00000000..c31eafd2 --- /dev/null +++ b/packages/web/src/utils/dmOriginFailover.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock useWebSocket imports that instanceStore / chatStore transitively depend on. +vi.mock('../hooks/useWebSocket', () => ({ + wsSend: vi.fn(), + wsSendAll: vi.fn(), + connectInstance: vi.fn(), + disconnectInstance: vi.fn(), + disconnectAllRemote: vi.fn(), +})); +vi.mock('../utils/federationOps', () => ({ + clearPasswordSyncTimers: vi.fn(), +})); + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom. +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub authStore to avoid localStorage access during module init. +vi.mock('../stores/authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub instanceStore to avoid TDZ crash from the module-level +// setApiForOriginResolver() call in instanceStore.ts. +// We use a factory so vitest hoisting sees a self-contained mock. +vi.mock('../stores/instanceStore', async () => { + const { create } = await import('zustand'); + const store = create<{ instances: unknown[] }>()(() => ({ instances: [] })); + return { useInstanceStore: store }; +}); + +// Stub voiceStore to avoid Zustand persist localStorage issues in jsdom. +// The test only needs getState()/setState() to verify voice state is untouched. +vi.mock('../stores/voiceStore', async () => { + const { create } = await import('zustand'); + const store = create<{ + activeDmCall: { dmChannelId: string } | null; + outgoingCall: { dmChannelId: string } | null; + incomingCall: unknown; + }>()(() => ({ + activeDmCall: null, + outgoingCall: null, + incomingCall: null, + })); + return { useVoiceStore: store }; +}); + +import { useSpaceStore } from '../stores/spaceStore'; +import { useChatStore } from '../stores/chatStore'; +import { useInstanceStore } from '../stores/instanceStore'; +import { failoverDmOriginsFromDisconnected } from './dmOriginFailover'; +import type { DmChannel, User } from '@backspace/shared'; +import type { ConnectedInstance as Inst } from '../stores/instanceStore'; + +function makeDm(id: string, federatedId: string | null): DmChannel { + return { id, federatedId, createdAt: 1000, members: [] }; +} + +function fakeUser(id: string): User { + return { + id, username: id, displayName: null, avatar: null, accentColor: null, + banner: null, bio: null, status: 'online', activities: [], createdAt: 1, + homeInstance: null, homeUserId: null, + } as any; +} + +function fakeInstance(origin: string, status: Inst['status']): Inst { + return { + origin, + label: origin, + token: 'tok', + user: fakeUser(`u-${origin}`), + username: 'u', + status, + api: {} as any, + }; +} + +beforeEach(() => { + useSpaceStore.getState().reset(); + useChatStore.setState({ + messages: new Map(), + typingUsers: new Map(), + hasMore: new Map(), + readStates: new Map(), + unreadChannels: new Set(), + channelAccessTimes: new Map(), + scrollPositions: new Map(), + currentChannelId: null, + }); + useInstanceStore.setState({ instances: [] }); + // Replace history.replaceState to observe URL writes in tests. + vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); +}); + +describe('failoverDmOriginsFromDisconnected', () => { + it('no-ops when disconnected origin has no DMs pinned to it', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('home-1', 'fed-aaa')], + channelOriginMap: new Map([['home-1', '']]), + dmAlternatives: new Map([['fed-aaa', new Map([['', 'home-1']])]]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe(''); + }); + + it('re-keys a DM to the connected home alternative', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + channelLastMessageIds: new Map([['b-1', 'msg-last-on-b']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + useChatStore.setState({ + messages: new Map([['b-1', []]]), + readStates: new Map([['b-1', 'prev']]), + unreadChannels: new Set(['b-1']), + currentChannelId: 'b-1', + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + const sp = useSpaceStore.getState(); + expect(sp.dmChannels.find(d => d.id === 'home-1')).toBeTruthy(); + expect(sp.dmChannels.find(d => d.id === 'b-1')).toBeUndefined(); + expect(sp.channelOriginMap.get('home-1')).toBe(''); + expect(sp.channelOriginMap.has('b-1')).toBe(false); + expect(sp.channelLastMessageIds.has('b-1')).toBe(false); + + const ch = useChatStore.getState(); + expect(ch.messages.has('b-1')).toBe(false); + expect(ch.readStates.has('b-1')).toBe(false); + expect(ch.unreadChannels.has('home-1')).toBe(true); + expect(ch.unreadChannels.has('b-1')).toBe(false); + expect(ch.currentChannelId).toBe('home-1'); + }); + + it('prefers home (empty-string origin) when multiple alternatives are connected', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['https://c.example', 'c-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ + instances: [ + fakeInstance('https://b.example', 'disconnected'), + fakeInstance('https://c.example', 'connected'), + ], + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe(''); + }); + + it('falls back to a connected remote when home is not an alternative', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['https://c.example', 'c-1'], + ])], + ]), + }); + useInstanceStore.setState({ + instances: [ + fakeInstance('https://b.example', 'disconnected'), + fakeInstance('https://c.example', 'connected'), + ], + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + const sp = useSpaceStore.getState(); + expect(sp.channelOriginMap.get('c-1')).toBe('https://c.example'); + expect(sp.dmChannels.find(d => d.id === 'c-1')).toBeTruthy(); + }); + + it('leaves the pin untouched when no alternatives are connected', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['https://c.example', 'c-1'], + ])], + ]), + }); + useInstanceStore.setState({ + instances: [ + fakeInstance('https://b.example', 'disconnected'), + fakeInstance('https://c.example', 'error'), + ], + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('b-1')).toBe('https://b.example'); + expect(useSpaceStore.getState().dmChannels.find(d => d.id === 'b-1')).toBeTruthy(); + }); + + it('skips DMs without a federatedId', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-local', null)], + channelOriginMap: new Map([['b-local', 'https://b.example']]), + dmAlternatives: new Map(), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('b-local')).toBe('https://b.example'); + }); + + it('retains oldOrigin → oldLocalId in dmAlternatives after rekey for fail-back', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + const alts = useSpaceStore.getState().dmAlternatives.get('fed-aaa'); + // Old primary (b) retained as alternative for possible future fail-back. + expect(alts?.get('https://b.example')).toBe('b-1'); + // Alternative map entry for the new primary's own origin is removed — it IS the primary now. + expect(alts?.has('')).toBe(false); + }); + + it('updates the URL via history.replaceState when rekeying the current channel', () => { + window.history.pushState({}, '', '/channels/@me/b-1'); + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + useChatStore.setState({ currentChannelId: 'b-1' }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(window.history.replaceState).toHaveBeenCalledWith( + expect.anything(), + '', + expect.stringContaining('/channels/@me/home-1'), + ); + }); + + it('does not touch voice state during failover (voice is handled separately)', async () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([['https://b.example', 'b-1'], ['', 'home-1']])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + const { useVoiceStore } = await import('../stores/voiceStore'); + const beforeActive = useVoiceStore.getState().activeDmCall; + const beforeOutgoing = useVoiceStore.getState().outgoingCall; + useVoiceStore.setState({ activeDmCall: { dmChannelId: 'b-1' } }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useVoiceStore.getState().activeDmCall?.dmChannelId).toBe('b-1'); + // Restore + useVoiceStore.setState({ activeDmCall: beforeActive, outgoingCall: beforeOutgoing }); + }); +}); diff --git a/packages/web/src/utils/dmOriginFailover.ts b/packages/web/src/utils/dmOriginFailover.ts new file mode 100644 index 00000000..38fc99c1 --- /dev/null +++ b/packages/web/src/utils/dmOriginFailover.ts @@ -0,0 +1,127 @@ +import { useSpaceStore } from '../stores/spaceStore'; +import { useChatStore } from '../stores/chatStore'; +import { useInstanceStore } from '../stores/instanceStore'; + +/** + * DM origin failover: when a remote instance disconnects mid-session, + * re-point every DM pinned to it onto a connected sibling that holds the + * same federated DM (via S2S mirroring). See + * `docs/superpowers/specs/2026-04-23-dm-origin-failover-design.md`. + * + * No-op when: + * - no DMs are pinned to the disconnected origin + * - pinned DMs have no `federatedId` (never-federated) + * - no alternative origin is currently connected + * + * Voice state (activeDmCall / outgoingCall / incomingCall) is intentionally + * NOT rewritten — a LiveKit session bound to the disconnected origin cannot + * migrate; voice cleans up through its own disconnect paths. + */ +export function failoverDmOriginsFromDisconnected(disconnectedOrigin: string): void { + const spaceState = useSpaceStore.getState(); + const { dmChannels, channelOriginMap, dmAlternatives } = spaceState; + + // Build the set of connected-origin candidates (home is always considered + // connected while auth is live — its WS disconnect path doesn't go through + // instanceStore at all). + const instances = useInstanceStore.getState().instances; + const connectedRemotes = new Set( + instances.filter(i => i.status === 'connected').map(i => i.origin), + ); + const isOriginConnected = (o: string): boolean => o === '' || connectedRemotes.has(o); + + type Rekey = { oldId: string; newId: string; newOrigin: string; federatedId: string }; + const rekeys: Rekey[] = []; + + for (const dm of dmChannels) { + if (channelOriginMap.get(dm.id) !== disconnectedOrigin) continue; + if (!dm.federatedId) continue; + + const byOrigin = dmAlternatives.get(dm.federatedId); + if (!byOrigin) continue; + + // Preference order: home ('') first, then any connected remote in + // insertion order (Map preserves insertion order in ES2015+). + let chosenOrigin: string | null = null; + let chosenLocalId: string | null = null; + if (byOrigin.has('') && isOriginConnected('') && '' !== disconnectedOrigin) { + chosenOrigin = ''; + chosenLocalId = byOrigin.get('')!; + } else { + for (const [altOrigin, altLocalId] of byOrigin) { + if (altOrigin === disconnectedOrigin) continue; + if (!isOriginConnected(altOrigin)) continue; + chosenOrigin = altOrigin; + chosenLocalId = altLocalId; + break; + } + } + if (chosenOrigin === null || chosenLocalId === null) continue; + if (chosenLocalId === dm.id) continue; // shouldn't happen, but guard + + rekeys.push({ + oldId: dm.id, + newId: chosenLocalId, + newOrigin: chosenOrigin, + federatedId: dm.federatedId, + }); + } + + if (rekeys.length === 0) return; + + for (const r of rekeys) { + rekeyDmChannel(r.oldId, r.newId, r.newOrigin, r.federatedId); + } +} + +/** + * Atomic rename of a DM across spaceStore + chatStore + URL. + * Exported for unit tests; not part of the public failover API. + */ +export function rekeyDmChannel( + oldId: string, + newId: string, + newOrigin: string, + federatedId: string, +): void { + useSpaceStore.setState((state) => { + const dmChannels = state.dmChannels.map(dm => + dm.id === oldId ? { ...dm, id: newId } : dm, + ); + + const channelOriginMap = new Map(state.channelOriginMap); + channelOriginMap.delete(oldId); + channelOriginMap.set(newId, newOrigin); + + const channelLastMessageIds = new Map(state.channelLastMessageIds); + channelLastMessageIds.delete(oldId); // old id's stored last-message id is origin-local + + // dmAlternatives update: remove the chosen origin's entry from the inner map + // (it IS the primary now — not an alternative), but RETAIN every other origin's + // entry including the old origin (so a later fail-back can use it without + // needing another ready round-trip). + const dmAlternatives = new Map>(); + for (const [fid, byOrigin] of state.dmAlternatives) { + const next = new Map(byOrigin); + if (fid === federatedId) next.delete(newOrigin); + if (next.size > 0) dmAlternatives.set(fid, next); + } + + return { dmChannels, channelOriginMap, channelLastMessageIds, dmAlternatives }; + }); + + useChatStore.getState().rekeyChannelState(oldId, newId); + + // URL update: if the user is viewing the DM whose id just changed, swap the + // last path segment in place. No router navigation — the chat view re-renders + // from the updated currentChannelId. + if (typeof window !== 'undefined') { + const path = window.location.pathname; + const marker = `/channels/@me/`; + const idx = path.indexOf(marker); + if (idx !== -1 && path.slice(idx + marker.length) === oldId) { + const nextPath = path.slice(0, idx + marker.length) + newId; + window.history.replaceState(window.history.state, '', nextPath + window.location.search); + } + } +}