Files
backspace/packages/web/src/stores/instanceStore.ts
T
Jannis Braun 95cb9d4df2 feat: add background retry scheduler for federation password sync
Replace the give-up-after-3-retries path with a persistent background
scheduler: 10 retries every 30s (5 min), then 12 retries every 5 min
(1 hr). Active timers are keyed by origin so rapid password changes
cancel and replace the previous loop. All timers are cleared on logout
via clearPasswordSyncTimers() called from instanceStore.reset().
2026-03-23 00:52:40 +01:00

741 lines
28 KiB
TypeScript

import { create } from 'zustand';
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared';
import { BackspaceApiClient, createApiClient, api } from '../api/client';
import { useAuthStore } from './authStore';
import { setApiForOriginResolver, setUserIdForOriginResolver, setOriginFromHostnameResolver, useSpaceStore } from './spaceStore';
import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket';
import { syncProfileToRemote } from '../utils/profileSync';
// Circular dependency: federationOps imports useInstanceStore, instanceStore imports this.
// Safe because both modules access each other lazily (at call time, not import time).
// clearPasswordSyncTimers itself does not reference useInstanceStore.
import { clearPasswordSyncTimers } from '../utils/federationOps';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ConnectedInstance {
origin: string;
label: string;
token: string;
user: User;
username: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
error?: string;
api: BackspaceApiClient;
}
interface CachedInstanceToken {
token: string;
label: string;
username: string;
pendingPasswordSync?: boolean;
}
const STORAGE_KEY_PREFIX = 'backspace_instances';
const LEGACY_STORAGE_KEY = 'backspace_instances';
function storageKey(userId: string): string {
return `${STORAGE_KEY_PREFIX}_${userId}`;
}
// ─── localStorage helpers ────────────────────────────────────────────────────
function loadCachedTokens(userId: string): Record<string, CachedInstanceToken> {
try {
const scopedKey = storageKey(userId);
const raw = localStorage.getItem(scopedKey);
if (raw) {
return JSON.parse(raw) as Record<string, CachedInstanceToken>;
}
// One-time migration: adopt legacy unscoped key if it exists
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
const parsed = JSON.parse(legacy) as Record<string, CachedInstanceToken>;
localStorage.setItem(scopedKey, legacy);
localStorage.removeItem(LEGACY_STORAGE_KEY);
return parsed;
}
return {};
} catch {
return {};
}
}
function saveCachedTokens(instances: ConnectedInstance[], userId: string, pendingSyncFlags?: Record<string, boolean>): void {
const cache: Record<string, CachedInstanceToken> = {};
// Load existing cache to preserve pendingPasswordSync flags
const existing = loadCachedTokens(userId);
for (const inst of instances) {
// Skip tokenless placeholders — writing an empty token would cause
// autoConnectAll to find a truthy cached entry with an empty bearer token
if (!inst.token) continue;
cache[inst.origin] = {
token: inst.token,
label: inst.label,
username: inst.username,
pendingPasswordSync: pendingSyncFlags?.[inst.origin] ?? existing[inst.origin]?.pendingPasswordSync,
};
}
localStorage.setItem(storageKey(userId), JSON.stringify(cache));
}
// ─── Network error detection ────────────────────────────────────────────────
/** Detect network-level failures (unreachable, DNS, timeout) vs application errors (401, etc.) */
function isNetworkError(err: unknown): boolean {
return err instanceof TypeError ||
(err instanceof Error && /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(err.message));
}
// ─── Error types ────────────────────────────────────────────────────────────
/** Thrown when the remote instance already has an account for this user with a different password. */
export class DifferentPasswordError extends Error {
constructor(public remoteUsername: string) {
super('Account exists with a different password on this instance');
this.name = 'DifferentPasswordError';
}
}
// ─── URL normalization ───────────────────────────────────────────────────────
function normalizeOrigin(url: string): string {
let normalized = url.trim();
// Add https:// if no protocol
if (!/^https?:\/\//i.test(normalized)) {
normalized = `https://${normalized}`;
}
try {
const parsed = new URL(normalized);
return parsed.origin; // "https://domain.com" — no path, no trailing slash
} catch {
throw new Error('Invalid URL');
}
}
// ─── API client resolution ───────────────────────────────────────────────────
// ─── Store ───────────────────────────────────────────────────────────────────
interface InstanceState {
instances: ConnectedInstance[];
isLoading: boolean;
error: string | null;
_autoConnectDone: boolean;
pendingSyncOrigins: string[];
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
removeInstance: (origin: string) => void;
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
reconnectInstance: (origin: string) => Promise<void>;
reauthenticateInstance: (origin: string, password: string) => Promise<void>;
updateInstanceToken: (origin: string, newToken: string) => void;
setPendingPasswordSync: (origin: string, pending: boolean) => void;
hasPendingPasswordSync: (origin: string) => boolean;
syncInstanceList: () => Promise<void>;
autoConnectAll: () => Promise<void>;
reset: () => void;
}
export const useInstanceStore = create<InstanceState>((set, get) => ({
instances: [],
isLoading: false,
error: null,
_autoConnectDone: false,
pendingSyncOrigins: [],
probeInstance: async (url: string) => {
const origin = normalizeOrigin(url);
// Reject self-connection
if (origin === window.location.origin) {
throw new Error('Cannot add your home instance as a remote instance');
}
// Reject duplicates
if (get().instances.some(i => i.origin === origin)) {
throw new Error('This instance is already connected');
}
// Probe with unauthenticated client
const tempClient = createApiClient(origin, () => null);
const info = await tempClient.instance.info();
return { ...info, origin };
},
connectToRemote: async (origin: string, password: string, displayName?: string) => {
const currentUser = useAuthStore.getState().user;
if (!currentUser) throw new Error('Not logged in');
set({ isLoading: true, error: null });
try {
// Step 1: Verify password against the instance we're currently browsing
const { valid } = await api.users.verifyPassword(password);
if (!valid) {
throw new Error('Incorrect password');
}
// Step 2: Compute the user's true home identity
// If we're a federated user (e.g. youruser@nova browsing orbit),
// homeInstance points to the real home, not window.location.host.
const trueHomeHost = currentUser.homeInstance ?? window.location.host;
const bareUsername = currentUser.username.includes('@')
? currentUser.username.split('@')[0]!
: currentUser.username;
const trueHomeUserId = currentUser.homeUserId ?? currentUser.id;
const targetHost = new URL(origin).host;
const targetIsHome = targetHost === trueHomeHost;
const tempClient = createApiClient(origin, () => null);
let response: AuthResponse | null = null;
let finalUsername: string;
if (targetIsHome) {
// Target IS the user's home instance — they already have a native account.
// Just login with bare username, no registration or homeInstance params.
finalUsername = bareUsername;
try {
response = await tempClient.auth.login({
username: bareUsername,
password,
});
} catch {
throw new DifferentPasswordError(bareUsername);
}
} else {
// Target is a remote/third-party instance — register as user@homeHost
finalUsername = `${bareUsername}@${trueHomeHost}`;
// 2a: Attempt registration with namespaced username
try {
response = await tempClient.auth.register({
username: finalUsername,
password,
displayName: displayName || currentUser.displayName || undefined,
homeInstance: trueHomeHost,
homeUserId: trueHomeUserId,
});
} catch (err) {
const message = (err as Error).message;
if (message.includes('already taken') || message.includes('409') ||
message.includes('Registration is currently closed') || message.includes('403')) {
// Already registered or registration closed — fall through to login
} else {
throw err;
}
}
// 2b: If registration didn't work, try login
if (!response) {
try {
response = await tempClient.auth.login({
username: finalUsername,
password,
});
} catch {
// Namespaced login failed — try legacy plain username as fallback
try {
response = await tempClient.auth.login({
username: bareUsername,
password,
});
} catch {
throw new DifferentPasswordError(bareUsername);
}
}
}
}
if (!response) {
throw new Error('Failed to authenticate with remote instance');
}
// Step 3: Complete connection
const info = await tempClient.instance.info();
const authenticatedClient = createApiClient(origin, () => response.token);
const instance: ConnectedInstance = {
origin,
label: info.name,
token: response.token,
user: response.user,
username: finalUsername,
status: 'connected',
api: authenticatedClient,
};
set((state) => {
const updated = [...state.instances, instance];
saveCachedTokens(updated, currentUser.id);
return { instances: updated, isLoading: false };
});
// Open WebSocket connection to the remote instance
connectInstance(origin, response.token);
// Sync home profile to new remote (fire-and-forget).
// Skip when target is home — home is the source of truth for profile data.
if (!targetIsHome) {
syncProfileToRemote(instance).catch((err) => {
console.warn(`[ProfileSync] Initial sync to ${origin} failed:`, err);
});
}
// Sync instance list to all instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
} catch (err) {
set({ isLoading: false, error: (err as Error).message });
throw err;
}
},
loginToRemote: async (origin: string, username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const tempClient = createApiClient(origin, () => null);
const response = await tempClient.auth.login({ username, password });
// Fetch instance info for the label
const info = await tempClient.instance.info();
const authenticatedClient = createApiClient(origin, () => response.token);
const instance: ConnectedInstance = {
origin,
label: info.name,
token: response.token,
user: response.user,
username: response.user.username,
status: 'connected',
api: authenticatedClient,
};
set((state) => {
const updated = [...state.instances, instance];
const userId = useAuthStore.getState().user?.id;
if (userId) saveCachedTokens(updated, userId);
return { instances: updated, isLoading: false };
});
// Open WebSocket connection to the remote instance
connectInstance(origin, response.token);
// Sync full home profile to remote (fire-and-forget)
syncProfileToRemote(instance).catch((err) => {
console.warn(`[ProfileSync] Login sync to ${origin} failed:`, err);
});
// Sync instance list to all instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
} catch (err) {
set({ isLoading: false, error: (err as Error).message });
throw err;
}
},
setInstanceStatus: (origin, status, error) => {
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin ? { ...i, status, error } : i
),
}));
},
removeInstance: (origin: string) => {
// Tear down WebSocket connection
disconnectInstance(origin);
set((state) => {
const updated = state.instances.filter(i => i.origin !== origin);
const userId = useAuthStore.getState().user?.id;
if (userId) saveCachedTokens(updated, userId);
return { instances: updated };
});
// Remove spaces from this instance from the space store
useSpaceStore.getState().removeInstanceSpaces(origin);
// Sync updated list to remaining instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
},
reconnectInstance: async (origin: string) => {
const inst = get().instances.find(i => i.origin === origin);
if (!inst || inst.status === 'connected' || inst.status === 'connecting') return;
// Tokenless placeholders can't reconnect — they need full re-authentication
if (!inst.token) return;
// Set to connecting
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin ? { ...i, status: 'connecting' as const, error: undefined } : i
),
}));
try {
const user = await inst.api.users.me();
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin ? { ...i, status: 'connected' as const, user, error: undefined } : i
),
}));
connectInstance(origin, inst.token);
} catch (err) {
if (isNetworkError(err)) {
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin
? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' }
: i
),
}));
connectInstance(origin, inst.token);
} else {
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin
? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' }
: i
),
}));
}
}
},
reauthenticateInstance: async (origin: string, password: string) => {
const inst = get().instances.find(i => i.origin === origin);
if (!inst) return;
// Remove the stale placeholder
set((state) => ({
instances: state.instances.filter(i => i.origin !== origin),
}));
// Remove its spaces from the space store
useSpaceStore.getState().removeInstanceSpaces(origin);
// Disconnect any lingering WS
disconnectInstance(origin);
// Re-connect through the standard flow (handles register/login)
const currentUser = useAuthStore.getState().user;
await get().connectToRemote(
origin,
password,
currentUser?.displayName || undefined,
);
// Clear pending password sync — connectToRemote uses the current password
// which updates the remote's stored hash through register/login
get().setPendingPasswordSync(origin, false);
},
updateInstanceToken: (origin: string, newToken: string) => {
set((state) => ({
instances: state.instances.map(i => {
if (i.origin !== origin) return i;
// Recreate API client with new token
const newApi = createApiClient(origin, () => newToken);
return { ...i, token: newToken, api: newApi };
}),
}));
const userId = useAuthStore.getState().user?.id;
if (userId) saveCachedTokens(get().instances, userId);
// Reconnect WebSocket with new token
disconnectInstance(origin);
connectInstance(origin, newToken);
},
setPendingPasswordSync: (origin: string, pending: boolean) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
// Update Zustand state (triggers React re-renders)
set((state) => ({
pendingSyncOrigins: pending
? state.pendingSyncOrigins.includes(origin)
? state.pendingSyncOrigins
: [...state.pendingSyncOrigins, origin]
: state.pendingSyncOrigins.filter(o => o !== origin),
}));
// Also persist to localStorage
const flags: Record<string, boolean> = { [origin]: pending };
saveCachedTokens(get().instances, userId, flags);
},
hasPendingPasswordSync: (origin: string) => {
return get().pendingSyncOrigins.includes(origin);
},
syncInstanceList: async () => {
// Prevent premature sync before autoConnectAll has populated all server-known
// instances — otherwise a user action (add/remove) would overwrite the server
// record with only the currently-loaded subset, permanently erasing the rest
if (!get()._autoConnectDone) return;
const { instances } = get();
const currentUser = useAuthStore.getState().user;
if (!currentUser) return;
// Build perspective-correct replicated instance lists.
// Each instance should store references to OTHER instances, never itself.
const homeOrigin = window.location.origin;
const homeUsername = currentUser.username.includes('@')
? currentUser.username.split('@')[0]!
: currentUser.username;
// Home list: all remotes (home never references itself)
const homeList: ReplicatedInstance[] = instances.map(inst => ({
origin: inst.origin,
username: inst.username,
}));
// Push to home instance
const homePromise = api.users.update({ replicatedInstances: homeList }).catch((err) => {
console.warn('Failed to sync instance list to home:', err);
});
// Push perspective-correct list to each remote instance:
// include home + all OTHER remotes, but exclude the remote's own origin
const connectedInstances = instances.filter(inst => inst.status === 'connected');
const remotePromises = connectedInstances.map(inst => {
const listForRemote: ReplicatedInstance[] = [
{ origin: homeOrigin, username: homeUsername },
...instances
.filter(other => other.origin !== inst.origin)
.map(other => ({ origin: other.origin, username: other.username })),
];
return inst.api.users.update({ replicatedInstances: listForRemote }).catch((err) => {
console.warn(`Failed to sync instance list to ${inst.origin}:`, err);
});
});
await Promise.all([homePromise, ...remotePromises]);
},
autoConnectAll: async () => {
const currentUser = useAuthStore.getState().user;
if (!currentUser || currentUser.replicatedInstances.length === 0) {
set({ _autoConnectDone: true });
return;
}
const cached = loadCachedTokens(currentUser.id);
// Split server-known instances into two groups:
// - withToken: have a cached token → attempt reconnection
// - withoutToken: no cached token → add as error placeholder
const withToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0]; entry: CachedInstanceToken }> = [];
const withoutToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0] }> = [];
for (const ri of currentUser.replicatedInstances) {
const origin = ri.origin || `https://${ri.domain}`;
// Never connect to ourselves — home WS is managed separately
if (origin === window.location.origin) continue;
if (get().instances.some(i => i.origin === origin)) continue; // already loaded
const entry = cached[origin];
if (entry) {
withToken.push({ origin, ri, entry });
} else {
withoutToken.push({ origin, ri });
}
}
// Immediately add tokenless placeholders so they're visible in Zustand
// (and therefore won't be erased by syncInstanceList)
if (withoutToken.length > 0) {
set((state) => {
const placeholders: ConnectedInstance[] = withoutToken.map(({ origin, ri }) => ({
origin,
label: new URL(origin).host,
token: '',
user: currentUser, // placeholder
username: ri.username,
status: 'error' as const,
error: 'Session expired — re-authenticate to reconnect',
api: createApiClient(origin, () => null),
}));
return { instances: [...state.instances, ...placeholders] };
});
}
// Connect instances with cached tokens in parallel
if (withToken.length > 0) {
const results = await Promise.allSettled(
withToken.map(async ({ origin, ri, entry: cachedEntry }) => {
// Create client with cached token
const client = createApiClient(origin, () => cachedEntry.token);
// Set as connecting
const connectingInstance: ConnectedInstance = {
origin,
label: cachedEntry.label || new URL(origin).host,
token: cachedEntry.token,
user: currentUser, // Placeholder until we verify
username: cachedEntry.username || ri.username,
status: 'connecting',
api: client,
};
set((state) => ({
instances: [...state.instances.filter(i => i.origin !== origin), connectingInstance],
}));
try {
// Verify the token is still valid
const user = await client.users.me();
// Fetch instance info for fresh label
let label = cachedEntry.label || new URL(origin).host;
try {
const info = await client.instance.info();
label = info.name;
} catch {
// Non-critical — keep cached label
}
// Backfill homeUserId if missing (existing federated users before this field existed)
if (user.homeInstance && !user.homeUserId) {
const homeUser = useAuthStore.getState().user;
if (homeUser) {
client.users.update({ homeUserId: homeUser.id }).catch(() => {});
}
}
// Backfill cached username if stale after server-side migration
// (e.g. "test" was renamed to "test@nova.ddns.net")
if (user.username !== cachedEntry.username) {
cachedEntry.username = user.username;
}
const connectedInstance: ConnectedInstance = {
origin,
label,
token: cachedEntry.token,
user,
username: user.username,
status: 'connected',
api: client,
};
set((state) => ({
instances: state.instances.map(i => i.origin === origin ? connectedInstance : i),
}));
// Open WebSocket connection now that we've verified the token
connectInstance(origin, cachedEntry.token);
// Sync home profile to reconnected remote (fire-and-forget)
syncProfileToRemote(connectedInstance).catch((err) => {
console.warn(`[ProfileSync] Reconnect sync to ${origin} failed:`, err);
});
} catch (err) {
if (isNetworkError(err)) {
// Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin
? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' }
: i
),
}));
// Start WebSocket — its built-in exponential backoff retry will auto-recover
// when the network path becomes available (e.g. user switches networks)
connectInstance(origin, cachedEntry.token);
} else {
// Auth failure (401, invalid token, etc.)
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin
? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' }
: i
),
}));
}
}
})
);
// Log any failures for debugging
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.warn(`autoConnectAll: ${failures.length}/${withToken.length} instances failed to connect`);
}
}
// Save final state to localStorage — persist ALL instances regardless of status
// so disconnected instances survive page reload and can auto-reconnect later
saveCachedTokens(get().instances, currentUser.id);
// Hydrate pendingSyncOrigins from localStorage cache and mark auto-connect done
const freshCached = loadCachedTokens(currentUser.id);
const pendingOrigins = Object.entries(freshCached)
.filter(([, v]) => v.pendingPasswordSync)
.map(([origin]) => origin);
set({ _autoConnectDone: true, pendingSyncOrigins: pendingOrigins });
},
reset: () => {
// Clean up space store for each connected remote instance before tearing down
const { instances } = get();
for (const inst of instances) {
useSpaceStore.getState().removeInstanceSpaces(inst.origin);
}
// Tear down all remote WebSocket connections
disconnectAllRemote();
clearPasswordSyncTimers();
set({ instances: [], isLoading: false, error: null, _autoConnectDone: false, pendingSyncOrigins: [] });
// Token cache preserved — scoped per user, survives logout for seamless reconnect
},
}));
// ─── API client resolution ───────────────────────────────────────────────────
// Register the resolver with spaceStore so getApiForOrigin() works everywhere.
// Placed after store creation so useInstanceStore is definitely initialized.
// This breaks the circular dependency: chatStore → spaceStore ← instanceStore
// instead of: chatStore → instanceStore → useWebSocket → chatStore (cycle).
setApiForOriginResolver((origin: string): BackspaceApiClient => {
if (!origin) return api;
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
if (!instance) return api;
return instance.api;
});
// ─── User ID resolution (federation) ──────────────────────────────────────────
// Maps an origin to the local user's ID on that remote instance.
// Used by voice join/leave to optimistically add/remove the correct user ID.
setUserIdForOriginResolver((origin: string): string | undefined => {
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
return instance?.user.id;
});
// ─── Hostname → origin resolution (federation) ────────────────────────────────
// Maps a user's homeInstance hostname to its full origin URL.
setOriginFromHostnameResolver((hostname: string): string => {
const inst = useInstanceStore.getState().instances.find(i => {
try { return new URL(i.origin).host === hostname; } catch { return false; }
});
return inst?.origin ?? '';
});