fix(federation): close re-attach final-review findings — client/server domain normalization, merge attachment repoint, empty-domain guard, test hardening

This commit is contained in:
Jannis Braun
2026-07-03 02:43:45 +02:00
parent 521aff6e52
commit d3af4f2170
9 changed files with 109 additions and 7 deletions
@@ -90,7 +90,9 @@ export function AccountPanel() {
const homeDomain = user.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
return instances.find(
(i) => i.status === 'connected'
&& i.origin.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase() === homeDomain,
// Portless hostname — must agree with the server's extractDomain
// (new URL(origin).hostname) so a ported home instance still matches.
&& new URL(i.origin).hostname.toLowerCase() === homeDomain,
) ?? null;
}, [instances, user?.homeInstance]);
@@ -104,7 +106,8 @@ export function AccountPanel() {
setReattachError(null);
try {
// Target domain = THIS instance (where the detached account lives).
const { token } = await homeConnection.api.auth.attachProof(window.location.host);
// Portless hostname to match the server's extractDomain contract.
const { token } = await homeConnection.api.auth.attachProof(window.location.hostname);
const res = await api.users.reattach({ token });
useAuthStore.getState().setUser(res.user);
addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000);
@@ -80,6 +80,38 @@ describe('maybeAutoReattach', () => {
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await maybeAutoReattach(detachedConn);
expect((homeConn.api as unknown as { auth: { attachProof: ReturnType<typeof vi.fn> } }).auth.attachProof).not.toHaveBeenCalled();
expect((detachedConn.api as unknown as { users: { reattach: ReturnType<typeof vi.fn> } }).users.reattach).not.toHaveBeenCalled();
});
it('mints the PORTLESS target host for a ported instance origin (matches server extractDomain)', async () => {
// Both instances served on a non-443 port. The server binds/verifies the
// proof against extractDomain(peer.origin) = new URL(origin).hostname, which
// is portless — so the client must mint the portless host too, or the
// exchange 401s forever. homeInstance is stored bare (portless hostname).
const homeConn = makeInstance({
origin: 'https://orbit.test:8443',
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:8443',
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);
// Portless — 'nova.test', NOT 'nova.test:8443'.
expect(attachProof).toHaveBeenCalledWith('nova.test');
expect(reattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) });
const stored = useInstanceStore.getState().instances.find(i => i.origin === 'https://nova.test:8443')!;
expect(stored.user.federationHomeOrphaned).toBe(false);
});
it('skips when the account is not detached', async () => {
+6 -3
View File
@@ -155,12 +155,12 @@ export async function maybeAutoReattach(instance: ConnectedInstance): Promise<vo
const primaryUser = useAuthStore.getState().user;
let homeApi: BackspaceApiClient | null = null;
let homeUsername: string | null = null;
if (primaryUser && !primaryUser.homeInstance && window.location.host.toLowerCase() === homeDomain) {
if (primaryUser && !primaryUser.homeInstance && window.location.hostname.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,
(i) => i.status === 'connected' && new URL(i.origin).hostname.toLowerCase() === homeDomain,
);
if (conn) {
homeApi = conn.api;
@@ -175,7 +175,10 @@ export async function maybeAutoReattach(instance: ConnectedInstance): Promise<vo
if (!detachedBase || detachedBase !== homeBase) return;
try {
const targetHost = new URL(instance.origin).host;
// Portless hostname — must match the server's extractDomain(peer.origin)
// (new URL(origin).hostname) so the proof's targetDomain binds/verifies on
// a non-443 port too. .host would carry the port and 401 forever.
const targetHost = new URL(instance.origin).hostname;
const { token } = await homeApi.auth.attachProof(targetHost);
const res = await instance.api.users.reattach({ token });
useInstanceStore.setState((state) => ({