From 344a429e98af11ed508c40825b9fff94f3f53a77 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:19:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20automatic=20re-attach=20on=20conne?= =?UTF-8?q?ct=20+=20AccountPanel=20fallback=20action=20(re-attach=20spec?= =?UTF-8?q?=20=C2=A73.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/systems/client-federation.md | 18 +++ packages/web/src/api/client.ts | 9 ++ .../AccountPanel.detachedNotice.test.tsx | 73 +++++++++-- .../modals/settingsPanels/AccountPanel.tsx | 60 ++++++++- .../stores/instanceStore.autoReattach.test.ts | 118 ++++++++++++++++++ packages/web/src/stores/instanceStore.ts | 71 +++++++++++ 6 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/stores/instanceStore.autoReattach.test.ts diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 628cf488..7930ca76 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -86,6 +86,18 @@ When a user adds a remote instance via the Connections settings: The same password is used across all instances. Password changes on the home instance are synced to remote instances automatically. +### Automatic Re-Attach on Connect (`maybeAutoReattach`, re-attach spec §3.4) + +When a home instance is reset, its established accounts on peers become **detached** (`federationHomeOrphaned = 1`) — sovereign local accounts nothing from the old domain can re-bind. The owner who re-registers on the reset home under the same username + password would otherwise end up with two permanently forked identities. `maybeAutoReattach(instance)` (exported from `instanceStore.ts`) closes that gap as the **primary** re-link UX, and runs fire-and-forget right after `connectInstance(...)` in **both** `connectToRemote` and `loginToRemote`. + +It performs the proof exchange **only** when all hold (else it returns silently — the manual fallback stays available): + +1. The just-connected account is detached (`user.federationHomeOrphaned && user.homeInstance`). +2. This client also holds an authenticated session on the account's **home domain** — the primary connection when browsing it (native primary user, host matches), else a `status === 'connected'` secondary instance in `instances`. +3. That home session's username base equals the detached account's username base (case-insensitive, via `parseFederatedUsername`) — the unambiguous "same name" case. A cross-name bind is manual-only (spec §2). + +Exchange: `homeSession.api.auth.attachProof(peerHost)` → `POST /api/auth/attach-proof` mints a one-time token on the home; `instance.api.users.reattach({ token })` → `POST /api/users/@me/reattach` on the peer verifies it over S2S and re-binds. On success the connection's `user`/`username` and the registry entry are updated, a "re-linked" toast fires, and `syncRegistry()` runs. On failure it only `console.warn`s — the connection itself is never torn down. + ### API Client Error Contract The shared API client (`packages/web/src/api/client.ts:298`) throws `new Error(body.error)` for non-2xx responses. The server's structured error code is on `err.message`; there is **no** `err.body` or `err.code` property. Catch handlers that need to map codes to UI messages should read `err.message` and pass it as both the code and the fallback to `mapServerErrorToMessage` (see `packages/web/src/utils/friendErrors.ts`). @@ -482,6 +494,12 @@ Modeled on the peering-approval surface above, the FederationPanel's `ResetClean - **Reset-detected banner** — one persistent accent-rose banner per peer with `status === 'needs_attention' && needsAttentionReason === 'peer_reset_detected'` (the `needsAttentionReason` field distinguishes a reset from a generic auth-failure, and now also `'repeer_incomplete'`). **Re-peer** runs `resetPeer(id)` **then** `initiatePeering({ remoteOrigin })` — reset-before-handshake so activation heals the stale graph against the new incarnation. **The result is surfaced honestly:** `initiatePeering` now returns `{ peer, verified }`; when `verified === false` (or the peer comes back `needs_attention`), or when it rejects with `409 PEER_EXISTS_RESET_REQUIRED`, the toast is a **warning** telling the admin the remote still holds stale peering and its admin must reset the **other** side, then Re-peer again — rather than a false success. A cryptographically-verified activation shows the success toast. The common one-side reset recovers in one click; a bidirectional-stale case names the side that must act. See `federation.md` "Trust re-establishment contract". - **Detached-accounts card** — informational, neutral-tier surface (no rose/urgency styling) for the reset incarnation's real accounts that now operate as sovereign local accounts (`FederationOrphanedAccount`: owned-spaces / membership / message counts). Copy: detached accounts keep working locally and owners sign in with their existing password. Cards render only for unacknowledged events (`orphanedAccounts.length > 0 && acknowledgedAt === null`; the endpoint still returns acknowledged events for audit). Per-account **Remove** reuses `api.admin.deleteUser(id)` (`DELETE /api/admin/users/:id`, full purge) for genuinely-abandoned accounts — a Remove on a space owner surfaces the existing `409 { ownedSpaces }` as a "transfer ownership first" toast instead of deleting. A per-event **Dismiss** footer calls `api.federation.acknowledgeResetEvent(origin)` (`POST /api/federation/reset-events/acknowledge`) then re-fetches — a real server-side acknowledgement (replacing the old client-only "Keep") that hides the card and removes the event from the badge count without touching any account. +### AccountPanel re-attach action (fallback, re-attach spec §3.4) + +The owner-facing side of re-attach. `AccountPanel` (`components/modals/settingsPanels/AccountPanel.tsx`) renders the detached-account notice whenever the self user is detached (`federationHomeOrphaned && homeInstance`). Below the informational copy it appends a **"Re-attach to ``"** action **only** when `instanceStore.instances` also holds a `status === 'connected'` connection whose origin host matches the account's `homeInstance` (`homeConnection`, memoized). This is the explicit fallback for what `maybeAutoReattach` deliberately skips: a different username on the new home (cross-name bind), or a home connection established after the detached connection. + +The button is a two-step armed confirm that names both identities — first click arms (`Confirm re-attach as `), second click mints and exchanges the proof: `homeConnection.api.auth.attachProof(window.location.host)` → `api.users.reattach({ token })`, then `useAuthStore.getState().setUser(res.user)` clears the flag so the notice disappears. Errors surface inline; without a home-domain connection the notice keeps only its informational copy. + --- ## 9. Relationship to S2S Federation diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 6c30d0d3..f17ded1a 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -68,6 +68,9 @@ import type { CheckInviteResponse, SpaceInviteRequest, SpaceInviteResponse, + AttachProofResponse, + ReattachRequest, + ReattachResponse, } from '@backspace/shared'; import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers'; @@ -99,6 +102,7 @@ export class BackspaceApiClient { login: (data: LoginRequest) => Promise; checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; checkInvite: (token: string) => Promise; + attachProof: (targetDomain: string) => Promise; }; readonly users: { @@ -112,6 +116,7 @@ export class BackspaceApiClient { getFederationRegistry: () => Promise<{ registry: FederationRegistryEntry[]; updatedAt: number }>; putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>; deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => Promise; + reattach: (data: ReattachRequest) => Promise; }; readonly spaceLayout: { @@ -387,6 +392,8 @@ export class BackspaceApiClient { request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false), checkInvite: (token: string) => request('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false), + attachProof: (targetDomain: string) => + request('POST', '/auth/attach-proof', { targetDomain }), }; this.users = { @@ -419,6 +426,8 @@ export class BackspaceApiClient { request( 'POST', '/users/@me/federation-identity/delete', data ), + reattach: (data: ReattachRequest) => + request('POST', '/users/@me/reattach', data), }; this.spaceLayout = { diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx index 2b805993..a230131a 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; import type { User } from '@backspace/shared'; // ── Store mocks ───────────────────────────────────────────────────────────── @@ -7,20 +7,27 @@ import type { User } from '@backspace/shared'; // that user through a mutable fixture and mock the store with a selector-aware // callable (mirrors the selector-mock idiom used across the web test suite). let currentUser: User | null = null; +// Instances backing the re-attach fallback action — mutated per test. +let currentInstances: unknown[] = []; const noop = vi.fn(); +const setUserMock = vi.fn(); +// The peer re-attach call (primary `api.users.reattach`), asserted by the +// two-step-confirm test. +const mockReattach = vi.fn(); interface AuthState { user: User | null; updateProfile: (...args: unknown[]) => unknown; changePassword: (...args: unknown[]) => unknown; + setUser: (user: User) => void; } vi.mock('../../../stores/authStore', () => ({ useAuthStore: Object.assign( (selector: (s: AuthState) => unknown) => - selector({ user: currentUser, updateProfile: noop, changePassword: noop }), + selector({ user: currentUser, updateProfile: noop, changePassword: noop, setUser: setUserMock }), { - getState: (): AuthState => ({ user: currentUser, updateProfile: noop, changePassword: noop }), + getState: (): AuthState => ({ user: currentUser, updateProfile: noop, changePassword: noop, setUser: setUserMock }), setState: vi.fn(), subscribe: vi.fn(), }, @@ -37,8 +44,8 @@ vi.mock('../../../stores/uiStore', () => ({ vi.mock('../../../stores/instanceStore', () => ({ useInstanceStore: Object.assign( - (selector: (s: { instances: unknown[] }) => unknown) => selector({ instances: [] }), - { getState: () => ({ instances: [] }), setState: vi.fn(), subscribe: vi.fn() }, + (selector: (s: { instances: unknown[] }) => unknown) => selector({ instances: currentInstances }), + { getState: () => ({ instances: currentInstances }), setState: vi.fn(), subscribe: vi.fn() }, ), })); @@ -49,9 +56,12 @@ vi.mock('../../../stores/transferStore', () => ({ ), })); -// api.uploads.url is referenced during render for avatar/banner sources. +// api.uploads.url is referenced during render for avatar/banner sources; +// api.users.reattach is the peer call the fallback action fires on confirm. vi.mock('../../../api/client', () => ({ - api: { uploads: { url: (f: string) => `/api/uploads/${f}` } }, + // reattach is wrapped so the top-level `mockReattach` const is dereferenced + // lazily at call time (vi.mock factories are hoisted above const init). + api: { uploads: { url: (f: string) => `/api/uploads/${f}` }, users: { reattach: (...args: unknown[]) => mockReattach(...args) } }, })); // Child modals are closed in these render cases; stub them so their transitive @@ -85,9 +95,27 @@ function makeUser(overrides: Partial = {}): User { const NOTICE = /This account is detached from its home instance\./i; +// A connected home-domain instance carrying the proof-mint API surface the +// fallback action calls. Only the fields AccountPanel touches are populated. +function makeHomeConnection(overrides: { + origin?: string; + username?: string; + attachProof?: ReturnType; +} = {}) { + return { + origin: overrides.origin ?? 'https://orbit.test', + username: overrides.username ?? 'youruser', + status: 'connected' as const, + api: { auth: { attachProof: overrides.attachProof ?? vi.fn() } }, + }; +} + beforeEach(() => { cleanup(); currentUser = null; + currentInstances = []; + setUserMock.mockReset(); + mockReattach.mockReset(); }); describe('AccountPanel detached-account notice', () => { @@ -111,3 +139,34 @@ describe('AccountPanel detached-account notice', () => { expect(screen.queryByText(NOTICE)).not.toBeInTheDocument(); }); }); + +describe('AccountPanel re-attach fallback action', () => { + it('shows the re-attach action when a connection to the home domain exists', () => { + currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' }); + currentInstances = [makeHomeConnection()]; + render(); + expect(screen.getByRole('button', { name: /re-attach to orbit\.test/i })).toBeInTheDocument(); + }); + + it('hides the re-attach action without a home-domain connection', () => { + currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' }); + currentInstances = []; + render(); + expect(screen.queryByRole('button', { name: /re-attach/i })).not.toBeInTheDocument(); + // Informational copy still present: + expect(screen.getByText(/detached from its home instance/i)).toBeInTheDocument(); + }); + + it('two-step confirm: first click arms, second click mints proof and calls reattach', async () => { + currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' }); + const attachProof = vi.fn().mockResolvedValue({ token: 'a'.repeat(64) }); + currentInstances = [makeHomeConnection({ attachProof })]; + mockReattach.mockResolvedValue({ success: true, user: makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' }) }); + + render(); + fireEvent.click(screen.getByRole('button', { name: /re-attach to orbit\.test/i })); + fireEvent.click(screen.getByRole('button', { name: /confirm re-attach/i })); + await waitFor(() => expect(mockReattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) })); + expect(attachProof).toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 3b838a3f..084a0d29 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { useAuthStore } from '../../../stores/authStore'; import { useUIStore } from '../../../stores/uiStore'; import { useInstanceStore } from '../../../stores/instanceStore'; @@ -77,6 +77,45 @@ export function AccountPanel() { const instances = useInstanceStore((s) => s.instances); const changePassword = useAuthStore((s) => s.changePassword); + // ── Detached-account re-attach (fallback path, re-attach spec §3.4) ── + // Explicit action shown only when this client also holds an active connection + // to the account's home domain. Two-step armed confirm names both identities + // before minting the proof. The primary/automatic path lives in instanceStore. + const [reattachArmed, setReattachArmed] = useState(false); + const [reattaching, setReattaching] = useState(false); + const [reattachError, setReattachError] = useState(null); + + const homeConnection = useMemo(() => { + if (!user?.homeInstance) return null; + const homeDomain = user.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); + return instances.find( + (i) => i.status === 'connected' + && i.origin.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase() === homeDomain, + ) ?? null; + }, [instances, user?.homeInstance]); + + const handleReattach = async () => { + if (!homeConnection) return; + if (!reattachArmed) { + setReattachArmed(true); + return; + } + setReattaching(true); + setReattachError(null); + try { + // Target domain = THIS instance (where the detached account lives). + const { token } = await homeConnection.api.auth.attachProof(window.location.host); + const res = await api.users.reattach({ token }); + useAuthStore.getState().setUser(res.user); + addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000); + } catch (err) { + setReattachError(err instanceof Error ? err.message : 'Re-attach failed'); + } finally { + setReattaching(false); + setReattachArmed(false); + } + }; + if (!user) return null; const effectiveDisplayName = displayName.trim() || user.username; @@ -269,6 +308,25 @@ export function AccountPanel() { This account is detached from its home instance.{' '} {user.homeInstance} was reset or is no longer available, so this account now operates locally on this instance — your profile and password are managed here. + {homeConnection && ( + <> + {' '}As {homeConnection.username} on{' '} + {user.homeInstance}, you can re-link this account — profile and presence will sync from there again. + + {reattachError &&
{reattachError}
} + + )} )} {/* ── Profile Customization ── */} diff --git a/packages/web/src/stores/instanceStore.autoReattach.test.ts b/packages/web/src/stores/instanceStore.autoReattach.test.ts new file mode 100644 index 00000000..ef2b25b9 --- /dev/null +++ b/packages/web/src/stores/instanceStore.autoReattach.test.ts @@ -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 & { 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 } }).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 } }).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 } }).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 } }).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 + }); +}); diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index dbff2b2e..8afc90a1 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -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 { + 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((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((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(() => {});