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
+18
View File
@@ -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. 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 ### 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`). 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". - **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. - **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 `<homeInstance>`"** 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 <homeUsername>`), 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 ## 9. Relationship to S2S Federation
+9
View File
@@ -68,6 +68,9 @@ import type {
CheckInviteResponse, CheckInviteResponse,
SpaceInviteRequest, SpaceInviteRequest,
SpaceInviteResponse, SpaceInviteResponse,
AttachProofResponse,
ReattachRequest,
ReattachResponse,
} from '@backspace/shared'; } from '@backspace/shared';
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers'; import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
@@ -99,6 +102,7 @@ export class BackspaceApiClient {
login: (data: LoginRequest) => Promise<AuthResponse>; login: (data: LoginRequest) => Promise<AuthResponse>;
checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>;
checkInvite: (token: string) => Promise<CheckInviteResponse>; checkInvite: (token: string) => Promise<CheckInviteResponse>;
attachProof: (targetDomain: string) => Promise<AttachProofResponse>;
}; };
readonly users: { readonly users: {
@@ -112,6 +116,7 @@ export class BackspaceApiClient {
getFederationRegistry: () => Promise<{ registry: FederationRegistryEntry[]; updatedAt: number }>; getFederationRegistry: () => Promise<{ registry: FederationRegistryEntry[]; updatedAt: number }>;
putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>; putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>;
deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => Promise<FederationIdentityDeleteResponse>; deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => Promise<FederationIdentityDeleteResponse>;
reattach: (data: ReattachRequest) => Promise<ReattachResponse>;
}; };
readonly spaceLayout: { readonly spaceLayout: {
@@ -387,6 +392,8 @@ export class BackspaceApiClient {
request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false), request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false),
checkInvite: (token: string) => checkInvite: (token: string) =>
request<CheckInviteResponse>('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false), request<CheckInviteResponse>('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false),
attachProof: (targetDomain: string) =>
request<AttachProofResponse>('POST', '/auth/attach-proof', { targetDomain }),
}; };
this.users = { this.users = {
@@ -419,6 +426,8 @@ export class BackspaceApiClient {
request<FederationIdentityDeleteResponse>( request<FederationIdentityDeleteResponse>(
'POST', '/users/@me/federation-identity/delete', data 'POST', '/users/@me/federation-identity/delete', data
), ),
reattach: (data: ReattachRequest) =>
request<ReattachResponse>('POST', '/users/@me/reattach', data),
}; };
this.spaceLayout = { this.spaceLayout = {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; 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'; import type { User } from '@backspace/shared';
// ── Store mocks ───────────────────────────────────────────────────────────── // ── 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 // 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). // callable (mirrors the selector-mock idiom used across the web test suite).
let currentUser: User | null = null; let currentUser: User | null = null;
// Instances backing the re-attach fallback action — mutated per test.
let currentInstances: unknown[] = [];
const noop = vi.fn(); 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 { interface AuthState {
user: User | null; user: User | null;
updateProfile: (...args: unknown[]) => unknown; updateProfile: (...args: unknown[]) => unknown;
changePassword: (...args: unknown[]) => unknown; changePassword: (...args: unknown[]) => unknown;
setUser: (user: User) => void;
} }
vi.mock('../../../stores/authStore', () => ({ vi.mock('../../../stores/authStore', () => ({
useAuthStore: Object.assign( useAuthStore: Object.assign(
(selector: (s: AuthState) => unknown) => (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(), setState: vi.fn(),
subscribe: vi.fn(), subscribe: vi.fn(),
}, },
@@ -37,8 +44,8 @@ vi.mock('../../../stores/uiStore', () => ({
vi.mock('../../../stores/instanceStore', () => ({ vi.mock('../../../stores/instanceStore', () => ({
useInstanceStore: Object.assign( useInstanceStore: Object.assign(
(selector: (s: { instances: unknown[] }) => unknown) => selector({ instances: [] }), (selector: (s: { instances: unknown[] }) => unknown) => selector({ instances: currentInstances }),
{ getState: () => ({ instances: [] }), setState: vi.fn(), subscribe: vi.fn() }, { 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', () => ({ 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 // Child modals are closed in these render cases; stub them so their transitive
@@ -85,9 +95,27 @@ function makeUser(overrides: Partial<User> = {}): User {
const NOTICE = /This account is detached from its home instance\./i; 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<typeof vi.fn>;
} = {}) {
return {
origin: overrides.origin ?? 'https://orbit.test',
username: overrides.username ?? 'youruser',
status: 'connected' as const,
api: { auth: { attachProof: overrides.attachProof ?? vi.fn() } },
};
}
beforeEach(() => { beforeEach(() => {
cleanup(); cleanup();
currentUser = null; currentUser = null;
currentInstances = [];
setUserMock.mockReset();
mockReattach.mockReset();
}); });
describe('AccountPanel detached-account notice', () => { describe('AccountPanel detached-account notice', () => {
@@ -111,3 +139,34 @@ describe('AccountPanel detached-account notice', () => {
expect(screen.queryByText(NOTICE)).not.toBeInTheDocument(); 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(<AccountPanel />);
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(<AccountPanel />);
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(<AccountPanel />);
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();
});
});
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { useAuthStore } from '../../../stores/authStore'; import { useAuthStore } from '../../../stores/authStore';
import { useUIStore } from '../../../stores/uiStore'; import { useUIStore } from '../../../stores/uiStore';
import { useInstanceStore } from '../../../stores/instanceStore'; import { useInstanceStore } from '../../../stores/instanceStore';
@@ -77,6 +77,45 @@ export function AccountPanel() {
const instances = useInstanceStore((s) => s.instances); const instances = useInstanceStore((s) => s.instances);
const changePassword = useAuthStore((s) => s.changePassword); 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<string | null>(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; if (!user) return null;
const effectiveDisplayName = displayName.trim() || user.username; const effectiveDisplayName = displayName.trim() || user.username;
@@ -269,6 +308,25 @@ export function AccountPanel() {
<span className="font-medium text-txt-primary">This account is detached from its home instance.</span>{' '} <span className="font-medium text-txt-primary">This account is detached from its home instance.</span>{' '}
{user.homeInstance} was reset or is no longer available, so this account now operates locally on {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. this instance your profile and password are managed here.
{homeConnection && (
<>
{' '}As <span className="font-medium text-txt-primary">{homeConnection.username}</span> on{' '}
{user.homeInstance}, you can re-link this account profile and presence will sync from there again.
<button
type="button"
onClick={handleReattach}
disabled={reattaching}
className="mt-2 block rounded-md bg-accent-amber/20 hover:bg-accent-amber/30 disabled:opacity-50 text-txt-primary px-3 py-1.5 text-xs font-medium transition-colors"
>
{reattaching
? 'Re-attaching…'
: reattachArmed
? `Confirm re-attach as ${homeConnection.username}`
: `Re-attach to ${user.homeInstance}`}
</button>
{reattachError && <div className="mt-1.5 text-accent-rose">{reattachError}</div>}
</>
)}
</div> </div>
)} )}
{/* ── Profile Customization ── */} {/* ── Profile Customization ── */}
@@ -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. // so a static import here does not create an import-time cycle.
import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover'; import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover';
import { useUIStore } from './uiStore'; import { useUIStore } from './uiStore';
import { parseFederatedUsername } from '../utils/identity';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── 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 ─────────────────────────────────────────────────── // ─── API client resolution ───────────────────────────────────────────────────
// ─── Registry helpers ──────────────────────────────────────────────────────── // ─── Registry helpers ────────────────────────────────────────────────────────
@@ -355,6 +420,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Open WebSocket connection to the remote instance // Open WebSocket connection to the remote instance
connectInstance(origin, response.token); 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) // Ensure server-to-server peering for DM relay (non-fatal)
try { try {
const peerResult = await api.federation.ensurePeered({ remoteOrigin: origin }); 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 // Open WebSocket connection to the remote instance
connectInstance(origin, response.token); 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) // Sync instance list to all instances (fire-and-forget)
get().syncInstanceList().catch(() => {}); get().syncInstanceList().catch(() => {});
get().syncRegistry().catch(() => {}); get().syncRegistry().catch(() => {});