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:
@@ -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 () => {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user