feat(web): automatic re-attach on connect + AccountPanel fallback action (re-attach spec §3.4)
This commit is contained in:
@@ -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 `<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
|
||||
|
||||
@@ -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<AuthResponse>;
|
||||
checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>;
|
||||
checkInvite: (token: string) => Promise<CheckInviteResponse>;
|
||||
attachProof: (targetDomain: string) => Promise<AttachProofResponse>;
|
||||
};
|
||||
|
||||
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<FederationIdentityDeleteResponse>;
|
||||
reattach: (data: ReattachRequest) => Promise<ReattachResponse>;
|
||||
};
|
||||
|
||||
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<CheckInviteResponse>('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false),
|
||||
attachProof: (targetDomain: string) =>
|
||||
request<AttachProofResponse>('POST', '/auth/attach-proof', { targetDomain }),
|
||||
};
|
||||
|
||||
this.users = {
|
||||
@@ -419,6 +426,8 @@ export class BackspaceApiClient {
|
||||
request<FederationIdentityDeleteResponse>(
|
||||
'POST', '/users/@me/federation-identity/delete', data
|
||||
),
|
||||
reattach: (data: ReattachRequest) =>
|
||||
request<ReattachResponse>('POST', '/users/@me/reattach', data),
|
||||
};
|
||||
|
||||
this.spaceLayout = {
|
||||
|
||||
+66
-7
@@ -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> = {}): 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<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(() => {
|
||||
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(<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 { 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<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;
|
||||
|
||||
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>{' '}
|
||||
{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 <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>
|
||||
)}
|
||||
{/* ── 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
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user