feat(web): automatic re-attach on connect + AccountPanel fallback action (re-attach spec §3.4)

This commit is contained in:
Jannis Braun
2026-07-03 02:19:51 +02:00
parent d45366c4ff
commit 344a429e98
6 changed files with 341 additions and 8 deletions
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { User } from '@backspace/shared';
import type { BackspaceApiClient } from '../api/client';
// ── Module mocks (mirror instanceStore.failover.test.ts) ─────────────────────
// These stub the side-effecting modules instanceStore pulls in at import time so
// the store loads cleanly under jsdom with no network, audio, or WS activity.
vi.mock('../utils/dmOriginFailover', () => ({
failoverDmOriginsFromDisconnected: vi.fn(),
}));
vi.mock('../hooks/useWebSocket', () => ({
connectInstance: vi.fn(),
disconnectInstance: vi.fn(),
disconnectAllRemote: vi.fn(),
}));
vi.mock('../utils/federationOps', () => ({ clearPasswordSyncTimers: vi.fn() }));
vi.mock('../audio/AudioManager', () => ({
AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn() }) },
}));
// Primary user is null: the auto-reattach helper must then locate the home
// session through the instances array (the SECONDARY-connection branch), so
// window.location.host is irrelevant to these tests.
vi.mock('./authStore', () => ({
useAuthStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({ user: null, token: null }),
{ getState: () => ({ user: null, token: null }), setState: vi.fn(), subscribe: vi.fn() }
),
}));
import { useInstanceStore, maybeAutoReattach } from './instanceStore';
import type { ConnectedInstance } from './instanceStore';
function makeInstance(overrides: Partial<ConnectedInstance> & { origin: string }): ConnectedInstance {
return {
label: 'x', token: 't', status: 'connected',
username: overrides.user?.username ?? 'u',
api: { auth: { attachProof: vi.fn() }, users: { reattach: vi.fn() } } as unknown as BackspaceApiClient,
user: { id: 'id', username: 'u' } as User,
...overrides,
};
}
beforeEach(() => {
useInstanceStore.setState({ instances: [], registry: new Map(), registryUpdatedAt: 0 });
});
describe('maybeAutoReattach', () => {
it('performs the token exchange when all conditions hold (same base, home session present)', async () => {
const homeConn = makeInstance({
origin: 'https://orbit.test',
username: 'youruser',
user: { id: 'new-home-1', username: 'youruser' } as User,
});
const attachProof = vi.fn().mockResolvedValue({ token: 'a'.repeat(64) });
(homeConn.api as unknown as { auth: { attachProof: typeof attachProof } }).auth.attachProof = attachProof;
const updatedUser = { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User;
const reattach = vi.fn().mockResolvedValue({ success: true, user: updatedUser });
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
(detachedConn.api as unknown as { users: { reattach: typeof reattach } }).users.reattach = reattach;
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await maybeAutoReattach(detachedConn);
expect(attachProof).toHaveBeenCalledWith('nova.test');
expect(reattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) });
const stored = useInstanceStore.getState().instances.find(i => i.origin === 'https://nova.test')!;
expect(stored.user.federationHomeOrphaned).toBe(false);
});
it('skips silently on username-base mismatch (cross-name binds are manual-only)', async () => {
const homeConn = makeInstance({ origin: 'https://orbit.test', username: 'hans', user: { id: 'h', username: 'hans' } as User });
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await maybeAutoReattach(detachedConn);
expect((homeConn.api as unknown as { auth: { attachProof: ReturnType<typeof vi.fn> } }).auth.attachProof).not.toHaveBeenCalled();
});
it('skips when the account is not detached', async () => {
const conn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [conn] });
await maybeAutoReattach(conn);
expect((conn.api as unknown as { users: { reattach: ReturnType<typeof vi.fn> } }).users.reattach).not.toHaveBeenCalled();
});
it('skips when no home-domain session exists', async () => {
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [detachedConn] });
await maybeAutoReattach(detachedConn);
expect((detachedConn.api as unknown as { users: { reattach: ReturnType<typeof vi.fn> } }).users.reattach).not.toHaveBeenCalled();
});
it('a failed exchange never throws and leaves the connection up', async () => {
const homeConn = makeInstance({ origin: 'https://orbit.test', username: 'youruser', user: { id: 'h', username: 'youruser' } as User });
(homeConn.api as unknown as { auth: { attachProof: ReturnType<typeof vi.fn> } }).auth.attachProof = vi.fn().mockRejectedValue(new Error('boom'));
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await expect(maybeAutoReattach(detachedConn)).resolves.toBeUndefined();
const stored = useInstanceStore.getState().instances.find(i => i.origin === 'https://nova.test')!;
expect(stored.status).toBe('connected');
expect(stored.user.federationHomeOrphaned).toBe(true); // unchanged; manual path remains
});
});
+71
View File
@@ -18,6 +18,7 @@ import { clearPasswordSyncTimers } from '../utils/federationOps';
// so a static import here does not create an import-time cycle.
import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover';
import { useUIStore } from './uiStore';
import { parseFederatedUsername } from '../utils/identity';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -134,6 +135,70 @@ export function isSelfOrigin(origin: string): boolean {
}
}
// ─── Automatic re-attach (re-attach spec §3.4) ────────────────────────────────
/**
* Automatic re-attach (re-attach spec §3.4): when a just-connected remote
* account is DETACHED and this client also holds an authenticated session on
* the account's home domain under the SAME username base, silently perform
* the proof exchange — the user has proven both identities, so the accounts
* re-link without interaction. Cross-name binds and every ambiguous case fall
* through to the explicit AccountPanel action. Fire-and-forget, non-fatal.
*/
export async function maybeAutoReattach(instance: ConnectedInstance): Promise<void> {
const remoteUser = instance.user;
if (!remoteUser.federationHomeOrphaned || !remoteUser.homeInstance) return;
const homeDomain = remoteUser.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
// An authenticated session on the account's home domain: the primary
// connection when we're browsing it, else a connected secondary instance.
const primaryUser = useAuthStore.getState().user;
let homeApi: BackspaceApiClient | null = null;
let homeUsername: string | null = null;
if (primaryUser && !primaryUser.homeInstance && window.location.host.toLowerCase() === homeDomain) {
homeApi = api;
homeUsername = primaryUser.username;
} else {
const conn = useInstanceStore.getState().instances.find(
(i) => i.status === 'connected' && new URL(i.origin).host.toLowerCase() === homeDomain,
);
if (conn) {
homeApi = conn.api;
homeUsername = conn.username;
}
}
if (!homeApi || !homeUsername) return;
// Unambiguous case only: same username base on both sides (spec §2/§3.4).
const detachedBase = parseFederatedUsername(remoteUser.username).baseName.toLowerCase();
const homeBase = parseFederatedUsername(homeUsername).baseName.toLowerCase();
if (!detachedBase || detachedBase !== homeBase) return;
try {
const targetHost = new URL(instance.origin).host;
const { token } = await homeApi.auth.attachProof(targetHost);
const res = await instance.api.users.reattach({ token });
useInstanceStore.setState((state) => ({
instances: state.instances.map((i) =>
i.origin === instance.origin ? { ...i, user: res.user, username: res.user.username } : i,
),
}));
// Registry mirrors the connection's identity — keep the re-bound username in sync.
const registry = upsertRegistryEntry(useInstanceStore.getState().registry, instance.origin, {
origin: instance.origin,
username: res.user.username,
remoteUserId: res.user.id,
});
useInstanceStore.setState({ registry, registryUpdatedAt: Date.now() });
useUIStore.getState().addToast(`Account re-linked with ${homeDomain}`, 'success');
useInstanceStore.getState().syncRegistry().catch(() => {});
} catch (err) {
// Non-fatal: the connection works either way; the explicit re-attach
// action in AccountPanel remains available.
console.warn('[federation] Auto re-attach failed:', err);
}
}
// ─── API client resolution ───────────────────────────────────────────────────
// ─── Registry helpers ────────────────────────────────────────────────────────
@@ -355,6 +420,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Open WebSocket connection to the remote instance
connectInstance(origin, response.token);
// Automatic re-attach for detached accounts (re-attach spec §3.4).
maybeAutoReattach(instance).catch(() => {});
// Ensure server-to-server peering for DM relay (non-fatal)
try {
const peerResult = await api.federation.ensurePeered({ remoteOrigin: origin });
@@ -432,6 +500,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Open WebSocket connection to the remote instance
connectInstance(origin, response.token);
// Automatic re-attach for detached accounts (re-attach spec §3.4).
maybeAutoReattach(instance).catch(() => {});
// Sync instance list to all instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
get().syncRegistry().catch(() => {});