0ae2a6a36d7b4c77e8e7d7df2a1049f4412e7099
max
  Fri Sep 18 07:03:44 2026 -0700
hgHubConnect: let a mirror hand out its own API keys, and stop swallowing links to another host

#Preview2 week - bugs introduced now will need a build patch to fix
Two of the three problems QA found on #38323.

The link in the mirror-only Hub Upload message did not go anywhere. The tab
handler in hgHubConnect.js catches every hgHubConnect link with a hash and turns
it into a tab switch, and 'Go to Hub Upload on genome.ucsc.edu' has a hash that
names a tab on the mirror too, so the click just reopened the tab the reader was
already on. It now only intercepts links to the page itself.

The API key section was decoupled from storeUserFiles, but only for display: the
Generate and Revoke buttons are cartJson requests, and both the javascript that
sends them and the code in main() that routes them were still inside the
storeUserFiles gate. A site with showHubApiKey on and hubSpace off therefore drew
two dead buttons. The key functions move out of hgMyData.js into a new
hubApiKey.js that the Hub Development tab includes on its own, main() routes a
cartJson request when either setting is on, and the hubSpace file commands stay
registered only when hubSpace is actually running.

The request also goes to this host's hgHubConnect now rather than to the login
host. Keys live in the central database of the server that issues them, so a key
made on genome-euro belongs in genome-euro's table.

refs #38323

diff --git src/hg/js/hubApiKey.js src/hg/js/hubApiKey.js
new file mode 100644
index 00000000000..88607714259
--- /dev/null
+++ src/hg/js/hubApiKey.js
@@ -0,0 +1,84 @@
+/* jshint esversion: 8 */
+
+/* The API key controls on the Hub Development tab of hgHubConnect.
+ *
+ * A key gets a script past the download CAPTCHA and lets hubtools upload, and the keys
+ * live in the central database of the server that handed them out. So this code is kept
+ * apart from the hub upload UI in hgMyData.js: a mirror can show the API key section
+ * (showHubApiKey) without running hubSpace (storeUserFiles), and then hgMyData.js and
+ * everything it pulls in are not on the page at all.
+ *
+ * For the same reason the request goes to this host's hgHubConnect and not to the login
+ * host: a key made on genome-euro belongs in genome-euro's central. */
+
+var hubApiKey = (function() {
+
+    function addSpinner(afterElementId) {
+        // put a spinner after the button that was just clicked, unless one is already there
+        if (document.getElementById("spinner")) {
+            return;
+        }
+        let spinner = document.createElement("i");
+        spinner.id = "spinner";
+        spinner.classList.add("fa", "fa-spinner", "fa-spin");
+        document.getElementById(afterElementId).after(spinner);
+    }
+
+    function removeSpinner() {
+        let spinner = document.getElementById("spinner");
+        if (spinner) {
+            spinner.remove();
+        }
+    }
+
+    function sendToThisHost(cartData, handleSuccess) {
+        // keys are per-central, so always talk to the hgHubConnect of the site being read
+        cart.setCgi("hgHubConnect");
+        cart.send(cartData, handleSuccess);
+        cart.flush();
+    }
+
+    function generate() {
+        let apiKeyInstr = document.getElementById("apiKeyInstructions");
+        let apiKeyDiv = document.getElementById("apiKey");
+
+        addSpinner("generateApiKey");
+
+        let handleSuccess = function(reqObj) {
+            apiKeyDiv.textContent = reqObj.apiKey;
+            apiKeyInstr.style.display = "block";
+            let revokeDiv = document.getElementById("revokeDiv");
+            revokeDiv.style.display = "block";
+            removeSpinner();
+
+            // remove the word 'already' from the message if we have just re-generated a key
+            let refreshSpan = document.getElementById("removeOnGenerate");
+            if (refreshSpan) {
+                refreshSpan.style.display = "none";
+            }
+        };
+
+        sendToThisHost({generateApiKey: {}}, handleSuccess);
+    }
+
+    function revoke() {
+        let apiKeyInstr = document.getElementById("apiKeyInstructions");
+
+        addSpinner("revokeApiKeys");
+
+        let handleSuccess = function(req) {
+            apiKeyInstr.style.display = "none";
+            removeSpinner();
+            let generateDiv = document.getElementById("generateDiv");
+            generateDiv.style.display = "block";
+            let revokeDiv = document.getElementById("revokeDiv");
+            revokeDiv.style.display = "none";
+        };
+
+        sendToThisHost({revokeApiKey: {}}, handleSuccess);
+    }
+
+    return { generate: generate,
+             revoke: revoke,
+           };
+}());