diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-27 22:21:07 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-27 22:21:07 +0200 |
| commit | 7422a777297347dc83e6c5b2e89c1a6fa99a36b0 (patch) | |
| tree | 9193b24a9a009f086a250dbdd67e277f1045ab1a /packages | |
| parent | 8c7e39b6dca758badec6867ab6610fd5e8d93d1e (diff) | |
| download | meshbay-7422a777297347dc83e6c5b2e89c1a6fa99a36b0.tar.gz | |
fix(hub): say "No operator paired" only when the node says so
The Node page showed the banner whenever operator_paired was not true, so a
node that had not yet read its roster -- one still signing in to the hub --
was reported unpaired while its pairing was intact. The node now answers null
until it knows; the page shows the banner for false only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/node-page.js | 16 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_node_page_pairing.py | 48 |
2 files changed, 60 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index a59c100..7fd7ab1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -13,6 +13,14 @@ import { HUB } from './hub-client.js'; // (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain // for potential future browser-side use. +// true / false from a node that has read its roster, null from one that has +// not yet (it publishes the roster only once it has signed in to the hub) -- +// which is not the same as having no operator. +export function pairedFrom(result) { + const v = result && result.operator_paired; + return v === true || v === false ? v : null; +} + function NodeServicePanel({ onChanged, token, username }) { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(''); @@ -202,7 +210,7 @@ export function NodePage({ groups, token, username }) { const [editIce, setEditIce] = useState(null); const [savingIce, setSavingIce] = useState(false); const [iceInput, setIceInput] = useState(''); - const [operatorPaired, setOperatorPaired] = useState(false); + const [operatorPaired, setOperatorPaired] = useState(null); const [pairBusy, setPairBusy] = useState(false); const [pairStatus, setPairStatus] = useState(''); const [nodeInfo, setNodeInfo] = useState(null); @@ -231,7 +239,7 @@ export function NodePage({ groups, token, username }) { } const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); - setOperatorPaired(!!result.operator_paired); + setOperatorPaired(pairedFrom(result)); setNodeSettings(result.settings || null); try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} setStatus('connected'); @@ -253,7 +261,7 @@ export function NodePage({ groups, token, username }) { try { const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); - setOperatorPaired(!!result.operator_paired); + setOperatorPaired(pairedFrom(result)); setNodeSettings(result.settings || null); try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} } catch {} @@ -711,7 +719,7 @@ export function NodePage({ groups, token, username }) { </div> <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} /> ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} - ${!operatorPaired && html` + ${operatorPaired === false && html` <div class="node-pair-banner"> <p>${t('node.pair_needed')}</p> <button class="btn btn-primary btn-small" diff --git a/packages/meshbay-hub/tests/test_node_page_pairing.py b/packages/meshbay-hub/tests/test_node_page_pairing.py new file mode 100644 index 0000000..435488e --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_page_pairing.py @@ -0,0 +1,48 @@ +""" +The Node page's "No operator paired" banner. + +A node publishes its roster only once it has signed in to the hub, and until +then it has no way to know who is paired. It used to answer `false` in that +window and the page took `!operator_paired` for "not paired" -- so a node stuck +waiting for its account told its operator to pair again, about a pairing that +was intact, and sent them after the wrong problem. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +NODE_PAGE = STATIC / "node-page.js" + + +def _source() -> str: + return NODE_PAGE.read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") +def test_paired_is_unknown_unless_the_node_says_true_or_false(tmp_path): + m = re.search(r"^export function pairedFrom\(.*?^\}", _source(), re.M | re.S) + assert m, "node-page.js no longer has pairedFrom" + script = tmp_path / "case.js" + cases = [{"operator_paired": True}, {"operator_paired": False}, + {"operator_paired": None}, {}, None, {"operator_paired": "yes"}] + script.write_text(m.group(0).replace("export ", "") + + f"\nconsole.log(JSON.stringify({json.dumps(cases)}.map(pairedFrom)));", + encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) + assert json.loads(out.stdout) == [True, False, None, None, None, None] + + +def test_the_banner_needs_an_explicit_false(): + src = _source() + banner = src.index('class="node-pair-banner"') + guard = src.rindex("${", 0, banner) + assert src[guard:banner].startswith("${operatorPaired === false &&"), ( + "the pairing banner must not show for a node that has not read its roster") + assert "useState(null)" in src.split("const [operatorPaired", 1)[1].split("\n", 1)[0] + assert "setOperatorPaired(!!" not in src |