← Commit history

Add live web-control demo harness (detect ab, read status, show the open-KiCad wall)

John Lauer ·0758ece744 ·2mo ago ·parent c7ba3ab
1 file changed +157
docs/web-control.mdadded+157
@@ -0,0 +1,157 @@+# Talking to Adom Bridge from a web page++This page is a **live demo**. If you are reading it on `https://wiki.adom.inc` in a+browser on a machine that is running Adom Bridge, the panel below actually reaches+into the copy of Adom Bridge on that machine and reports back. Nothing here is a+mock-up: the buttons make real `fetch()` calls to `http://127.0.0.1:24680`, Adom+Bridge's loopback API.++## What a web page can and cannot do (read this first)++Adom Bridge runs a small HTTP API on the loopback address `127.0.0.1:24680`. If any+website could reach it, a malicious page could drive your computer. So the surface a+web page is allowed to touch is deliberately tiny, and this demo shows exactly where+the line is.++- **Allowed from the web, today:** `GET /health` and `GET /status`. Both are+  read-only: liveness, version, which bridges are installed. That is enough for a+  page to light up an "Open in KiCad" button only when KiCad and Adom Bridge are+  actually present.+- **Refused from the web, today:** `POST /command` (the verb-running endpoint) is+  hard-refused for every web origin with HTTP 403 `web_forbidden`. So no web page+  can change your machine. The "Try to open KiCad" button below deliberately hits+  that wall so you can watch it get refused.+- **Only `https://wiki.adom.inc` gets in at all.** Adom Bridge returns the CORS+  headers that let the browser read the response *only* when the request's `Origin`+  is exactly `https://wiki.adom.inc`. Any other site gets nothing. This is why the+  demo only works when you are reading it on the real wiki.++Actually *running* a verb like `kicad_open_board` from a web button is a planned next+phase: it will require a native Adom Bridge consent dialog (you approve the specific+action in Adom Bridge's own window, which no web page can script) layered under a+per-user cloud-key handshake. Until that ships, the answer to "can the wiki open+KiCad for me?" is: it can *detect* that it could, and it is *walled off* from doing+it. That wall is the feature.++<div id="ab-demo" style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;border:1px solid #30363d;border-radius:10px;background:#0d1117;color:#e6edf3;padding:18px;margin:18px 0;max-width:760px">+  <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">+    <span id="ab-dot" style="width:12px;height:12px;border-radius:50%;background:#484f58;display:inline-block"></span>+    <strong style="font-size:16px">Adom Bridge web control demo</strong>+  </div>+  <div style="font-size:12px;color:#8b949e;margin-bottom:14px">+    This iframe's origin: <code id="ab-origin" style="color:#00e6dc">checking…</code>+    &nbsp;·&nbsp; API: <code style="color:#8b949e">http://127.0.0.1:24680</code>+  </div>+  <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">+    <button id="ab-detect" style="background:#00b8b0;color:#04211f;border:0;border-radius:6px;padding:8px 14px;font-weight:600;cursor:pointer">Detect Adom Bridge</button>+    <button id="ab-status" style="background:#1c2128;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:8px 14px;cursor:pointer">Read status</button>+    <button id="ab-kicad" style="background:#1c2128;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:8px 14px;cursor:pointer">Try to open KiCad (should be blocked)</button>+  </div>+  <pre id="ab-log" style="background:#010409;border:1px solid #21262d;border-radius:6px;padding:12px;font-size:12px;line-height:1.5;max-height:300px;overflow:auto;white-space:pre-wrap;margin:0;color:#c9d1d9">Ready. Click "Detect Adom Bridge" to begin.</pre>+  <script>+  (function () {+    var AB = "http://127.0.0.1:24680";+    var log = document.getElementById("ab-log");+    var dot = document.getElementById("ab-dot");+    document.getElementById("ab-origin").textContent = location.origin || "(null — sandboxed iframe)";+    function line(msg, color) {+      var t = new Date().toLocaleTimeString();+      log.textContent += "\n[" + t + "] " + msg;+      if (color) { log.style.color = color; }+      log.scrollTop = log.scrollHeight;+    }+    function reset() { log.textContent = "—"; log.style.color = "#c9d1d9"; }++    document.getElementById("ab-detect").onclick = function () {+      reset();+      line("GET " + AB + "/health …");+      fetch(AB + "/health")+        .then(function (r) { return r.ok ? r.json() : Promise.reject("HTTP " + r.status); })+        .then(function (j) {+          if (j && j.service === "adom-bridge") {+            dot.style.background = "#00b8b0";+            line("Adom Bridge is RUNNING on this machine. service=" + j.service, "#7ee787");+            line(JSON.stringify(j, null, 2));+          } else {+            line("Reached something, but it is not Adom Bridge: " + JSON.stringify(j), "#e6c86c");+          }+        })+        .catch(function (e) {+          dot.style.background = "#484f58";+          line("No Adom Bridge reachable (" + e + ").", "#8b949e");+          line("Either it is not installed/running here, or you are not on https://wiki.adom.inc (the only origin it answers). Mixed-content note: browsers allow an HTTPS page to fetch http://127.0.0.1 as a trustworthy loopback, so that is not the blocker.");+        });+    };++    document.getElementById("ab-status").onclick = function () {+      reset();+      line("GET " + AB + "/status …");+      fetch(AB + "/status")+        .then(function (r) { return r.ok ? r.json() : Promise.reject("HTTP " + r.status); })+        .then(function (j) {+          dot.style.background = "#00b8b0";+          line("Status (read-only, allowed from the web):", "#7ee787");+          line(JSON.stringify(j, null, 2));+        })+        .catch(function (e) { line("status failed: " + e, "#f0883e"); });+    };++    document.getElementById("ab-kicad").onclick = function () {+      reset();+      line("POST " + AB + "/command  {kicad_open_board}  — expecting this to be REFUSED …");+      fetch(AB + "/command", {+        method: "POST",+        headers: { "Content-Type": "application/json" },+        body: JSON.stringify({ app: "kicad", command: "kicad_open_board", args: {} })+      })+        .then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j }; }); })+        .then(function (res) {+          if (res.status === 403 || (res.body && res.body.errorCode === "web_forbidden")) {+            line("REFUSED, as designed. HTTP " + res.status + " errorCode=" + (res.body && res.body.errorCode), "#7ee787");+            line("This is the safety wall: a web page cannot run a machine-changing verb. " ++                 "The consent-gated action phase will change this to 'ask the human in Adom Bridge first'.");+            line(JSON.stringify(res.body, null, 2));+          } else {+            line("UNEXPECTED — POST /command was NOT refused (HTTP " + res.status + "). " ++                 "That would be a security regression; please report it.", "#f85149");+            line(JSON.stringify(res.body, null, 2));+          }+        })+        .catch(function (e) {+          line("The browser blocked the POST before Adom Bridge answered (" + e + ").", "#e6c86c");+          line("That is also a refusal: with no CORS grant for a non-allowlisted origin, the browser will not even let page JS read the response.");+        });+    };+  })();+  </script>+</div>++## How it works under the hood++The gate lives in `src-tauri/src/direct_api.rs` (`web_gate` middleware). Every layer+must pass:++1. **Exact origin allowlist.** CORS headers are returned only for+   `Origin: https://wiki.adom.inc` (never `*`). The browser sets `Origin` and page+   JS cannot forge it, so it reliably identifies the site.+2. **Never `*.adom.cloud`, never a wildcard.** Any Adom user can be handed an+   `*.adom.cloud` subdomain, so allowlisting that pattern would let a nefarious+   user's page drive machines. The allowlist is exactly one first-party origin.+3. **Loopback Host check** (anti-DNS-rebind): the `Host` header must be a loopback+   literal.+4. **Private Network Access preflight:** a public-to-loopback fetch triggers the+   PNA preflight, which Adom Bridge answers only for the allowlisted origin.+5. **Path scope:** a web origin may reach only `/health` and `/status`. Everything+   else, including `/command`, is `web_forbidden`.++The fixed loopback port is `24680` (reserved and stable across reboots). For the+full ab-side reference, see the `adom-bridge-web-integration` skill.++## What this page is testing++Because it renders inside the wiki's HTML iframe, this page is also a live test of+whether that iframe's origin actually satisfies the gate. Watch the "This iframe's+origin" line: if it reads `https://wiki.adom.inc`, the CORS grant applies and the+detect/status buttons work. If it reads `null` (a sandboxed iframe), the browser+strips the origin and even the read-only calls are refused, which tells us the+harness has to run from a first-party origin rather than a sandboxed frame.