diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 190 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/hub_client.py | 22 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/media_cache.py | 8 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/musicbrainz.py | 33 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 26 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 21 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 61 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 32 |
8 files changed, 251 insertions, 142 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 74ab7b1..779ad91 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -240,6 +240,11 @@ class NodeDaemon: session = await self._login_with_retry(hub) self._state["endpoint_hint"] = session.node_id + try: + session.email = await hub.fetch_owner_email() + except Exception as e: + log.warning("Could not fetch owner email: %s", e) + # 4. Bundle store (P2P GEK bundles) data_dir = self._config.data_dir data_dir.mkdir(parents=True, exist_ok=True) @@ -433,12 +438,11 @@ class NodeDaemon: # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own # enricher (mutagen, not ffmpeg) and its own MusicBrainz client. - # No token to read here (§3.1): only a contact string, and unset - # simply means the client makes no calls (musicbrainz.py). + # The User-Agent contact is the owner's hub email, resolved at + # login — no roster setting or env var needed any more. self._audio_enricher = AudioEnricher(self._media_cache) - self._musicbrainz_client = MusicBrainzClient(roster=self._roster) - musicbrainz_contact = await self._roster.musicbrainz_contact() - self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact) + self._musicbrainz_client = MusicBrainzClient(owner_email=session.email) + self._state["musicbrainz_contact_configured"] = bool(session.email) # 6d. Photos app (docs/photos.md) — same media_cache.db, its own # enricher (Pillow, not ffmpeg/mutagen). No credential, no @@ -1570,11 +1574,12 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "status", "ui", "gek-init", "gek", - "operator", "member", "group", "file", + choices=["init", "reset", "status", "ui", "gek-init", + "gek", "operator", "member", "group", "file", "denylist", "reload", "restart-daemon", "calibrate-argon2"], - help="init: write example config | status: node state and keys " + help="init: provision config + keystore | reset: erase all " + "node state | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " @@ -1590,6 +1595,10 @@ def main() -> None: help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " "denylist clear") + parser.add_argument("--hub-url", default=None, + help="hub URL, for init (e.g. https://meshbay.org)") + parser.add_argument("--username", default=None, + help="hub username, for init") parser.add_argument("--dir", default=None, help="shared directory, for group add") parser.add_argument("--upload-dir", default=None, @@ -1607,7 +1616,7 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", "member", "group", "file", "denylist", "reload", - "restart-daemon") + "restart-daemon", "reset") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -1615,19 +1624,145 @@ def main() -> None: if args.command == "init": cfg_path = args.config or DEFAULT_CONFIG_PATH - if not cfg_path.exists(): - write_example_config(cfg_path) - print(f"Config written to {cfg_path}") + config_dir = cfg_path.parent + config_dir.mkdir(parents=True, exist_ok=True) + + hub_url = args.hub_url + username = args.username + + if not hub_url: + hub_url = input("Hub URL [https://meshbay.org]: ").strip() or "https://meshbay.org" + if not username: + username = input("Hub username: ").strip() + if not username: + print("Username is required.") + sys.exit(1) + + if cfg_path.exists(): + existing = cfg_path.read_text() + import re as _re + m = _re.search(r'username\s*=\s*"([^"]*)"', existing) + existing_user = m.group(1) if m else "" + if existing_user and existing_user != "myusername" and existing_user != username: + print(f"Config already exists with username {existing_user!r}.") + print("This node belongs to another operator. Use 'meshbay-node reset' first.") + sys.exit(1) + if existing_user in ("", "myusername"): + updated = _re.sub( + r'(username\s*=\s*)"[^"]*"', rf'\1"{username}"', existing) + updated = _re.sub( + r'(url\s*=\s*)"[^"]*"', rf'\1"{hub_url}"', updated, count=1) + cfg_path.write_text(updated) + print(f"Config updated: username={username}, hub={hub_url}") + else: + print(f"Config already exists: {cfg_path}") else: - print(f"Config already exists: {cfg_path}") + unlock_file = config_dir / "unlock.key" + toml_lines = [ + "[hub]", + f'url = "{hub_url}"', + f'username = "{username}"', + "", + "[node]", + "quic_port = 19010", + "ui_port = 18000", + "", + "[keystore]", + f'unlock_file = "{unlock_file}"', + "", + ] + cfg_path.write_text("\n".join(toml_lines) + "\n") + os.chmod(cfg_path, 0o600) + print(f"Config written to {cfg_path}") + + unlock_file = config_dir / "unlock.key" + if not unlock_file.exists(): + import secrets + key = secrets.token_urlsafe(32) + unlock_file.write_text(key + "\n") + os.chmod(unlock_file, 0o600) + print(f"Unlock key created: {unlock_file}") + cfg = load_config(cfg_path) if cfg.keystore.path.exists(): print(f"Keystore already exists: {cfg.keystore.path}") + keys = load_keystore( + path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) else: keys = create_keystore( path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) print(f"Keystore created: {cfg.keystore.path}") - print(f"Node key: {keys.pk_ed25519_b64}") + + print(f"Node key: {keys.pk_ed25519_b64}") + print() + print("Next steps:") + print(f" 1. Link this node key on {hub_url} → Settings → Link Node") + print(" 2. systemctl --user enable --now meshbay-node") + print(" 3. meshbay-node group add <name> --dir /path/to/files") + print(" 4. meshbay-node gek init") + print(" 5. meshbay-node operator pair") + return + + if args.command == "reset": + import shutil + + config_dir = Path.home() / ".config" / "meshbay" + data_dir = Path.home() / ".local" / "share" / "meshbay" + state_dir = Path.home() / ".local" / "state" / "meshbay" + + items = [] + for d in (config_dir, data_dir): + if d.exists(): + for child in sorted(d.iterdir()): + items.append(child) + + if not items: + print("Nothing to reset — no node state found.") + return + + print("This will permanently erase all node state:") + for p in items: + print(f" {p}") + print() + print("WARNING: a new keystore means a new identity. All group") + print("memberships, operator pairings, and invitations are lost.") + + if not args.yes: + answer = input("\nProceed? [y/N] ").strip().lower() + if answer != "y": + print("Aborted.") + return + + import subprocess as _sp + import json as _json + import urllib.request + import urllib.error + + token_file = data_dir / "ui-token" + if token_file.exists(): + try: + cfg = Config(config_dir / "node.toml") + tok = token_file.read_text().strip() + url = (f"http://127.0.0.1:{cfg.node.ui_port}" + f"/api/unlink?t={tok}") + req = urllib.request.Request(url, method="DELETE") + with urllib.request.urlopen(req, timeout=5) as r: + _json.loads(r.read()) + print("Unlinked node key from hub.") + except Exception: + print("Could not unlink from hub (daemon not reachable).") + + _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"], + capture_output=True) + + for d in (config_dir, data_dir): + if d.exists(): + shutil.rmtree(d) + print(f"Removed {d}") + if state_dir.is_dir(): + shutil.rmtree(state_dir) + print(f"Removed {state_dir}") + print("Node state erased. Run 'meshbay-node init' to start over.") return if args.command == "calibrate-argon2": @@ -1668,6 +1803,33 @@ def main() -> None: f" files {live.get('total_files', 0)}" f" peers {live.get('webrtc_peers', 0)}") print(f"admin UI meshbay-node ui") + + needs = live.get("needs", []) + if needs: + _GUIDANCE = { + "node_key_link": ( + "Link node key", + f"Copy the node key above and paste it in Settings → Link Node on {cfg.hub.url}"), + "group_add": ( + "Add a group", + "meshbay-node group add <name> --dir /path/to/files"), + "operator_pair": ( + "Pair as operator", + "meshbay-node operator pair"), + } + print() + print("action needed:") + for need in needs: + if need.startswith("gek_init:"): + name = need.split(":", 1)[1] + print(f" → Initialize group key for {name}") + print(f" meshbay-node gek init --group \"{name}\"") + elif need in _GUIDANCE: + label, hint = _GUIDANCE[need] + print(f" → {label}") + print(f" {hint}") + else: + print(f" → {need}") else: print("daemon not running") diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 92c39db..1a746bd 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -41,6 +41,7 @@ class HubSession: refresh_token: str hub_pk_pem: bytes # cached hub Ed25519 public key node_id: str = "" + email: str = "" _token_exp: int = 0 @property @@ -203,6 +204,27 @@ class HubClient: r.raise_for_status() return r.json().get("groups", []) + # ── Owner profile ──────────────────────────────────────────────────────── + + async def fetch_owner_email(self) -> str: + """Fetch the authenticated user's email from the hub.""" + if self._session is None: + raise RuntimeError("Not logged in") + await self.ensure_fresh_token() + r = await self._http.get("/v1/users/me", + headers=self._session.auth_headers) + r.raise_for_status() + return r.json().get("email", "") + + async def unlink_node_key(self) -> None: + """Clear pk_node_ed25519 on the hub (best-effort before reset).""" + if self._session is None: + return + await self.ensure_fresh_token() + r = await self._http.delete("/v1/users/me/node_key", + headers=self._session.auth_headers) + r.raise_for_status() + # ── User pubkey lookup ──────────────────────────────────────────────────── async def get_user_pubkeys(self, username: str) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 63cda41..707a046 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -3,10 +3,10 @@ MeshBay Node — TMDB/MusicBrainz metadata and thumbnail cache, shared by the Videos and Music group apps. Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as -`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`/ -`musicbrainz_contact`, docs/musicbay.md §6) living in `group_settings` under -the `group_id=""` sentinel (docs/mediacenter.md §5.5): the credential/budget -is one operator's, and a thumbnail or cover image is the same bytes +`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`, +docs/musicbay.md §6) living in `group_settings` under the `group_id=""` +sentinel (docs/mediacenter.md §5.5): the credential/budget is one +operator's, and a thumbnail or cover image is the same bytes regardless of which group happens to share the file. The `file_mbid`/ `mbid_meta` tables below are the Music app's equivalent of `file_tmdb`/ `tmdb_meta`, sharing the same `thumbs` table for cover art (a MusicBrainz diff --git a/packages/meshbay-node/src/meshbay_node/musicbrainz.py b/packages/meshbay-node/src/meshbay_node/musicbrainz.py index 6ca1cf4..59f3778 100644 --- a/packages/meshbay-node/src/meshbay_node/musicbrainz.py +++ b/packages/meshbay-node/src/meshbay_node/musicbrainz.py @@ -17,40 +17,25 @@ account, only: enforced by convention (and by MusicBrainz throttling abusive clients), not a token bucket handed out by the server. -Contact resolution order (docs/musicbay.md §3.2), same shape as tmdb.py's -token resolution: - - 1. an operator-supplied contact string (roster.py group_settings, - group_id="") - 2. the MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT environment variable - 3. none — MusicBrainz lookups are inert (callers get an empty result, - never an exception). Deliberately **not** falling back to a generic - User-Agent: sending an unidentified client to a service that polices - its User-Agent policy risks the node's IP being blocked, which is a - worse failure than "no music metadata yet". - -No literal contact value lives in this file, for the same reason tmdb.py -carries no literal token — see docs/musicbay.md §3.2 on why a personal -address must never land in source control. +Contact resolution: the node owner's hub account email, fetched once at +login via ``GET /v1/users/me`` and passed to this client at construction. +If the owner has no email on file, lookups are inert (callers get an +empty result, never an exception). """ import asyncio import difflib import logging -import os import re import time import httpx -from meshbay_node.roster import Roster - log = logging.getLogger(__name__) _BASE_URL = "https://musicbrainz.org/ws/2/" _COVER_ART_BASE = "https://coverartarchive.org/release/" _TIMEOUT = 10.0 -_DEFAULT_CONTACT_ENV = "MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT" _APP_NAME = "MeshBay-Node" # MusicBrainz's own stated courtesy limit for unauthenticated use. Enforced @@ -112,9 +97,9 @@ def _best_match_release(artist: str, album: str, results: list[dict]) -> tuple[d class MusicBrainzClient: """One instance per node, holding the resolved contact and an httpx client.""" - def __init__(self, roster: Roster | None = None, + def __init__(self, owner_email: str = "", transport: httpx.AsyncBaseTransport | None = None): - self._roster = roster + self._owner_email = owner_email # `transport` is a test-only seam (httpx.MockTransport) — production # callers never pass it. self._client = httpx.AsyncClient(timeout=_TIMEOUT, transport=transport) @@ -125,11 +110,7 @@ class MusicBrainzClient: await self._client.aclose() async def _resolve_contact(self) -> str | None: - if self._roster is not None: - contact = await self._roster.musicbrainz_contact() - else: - contact = None - return contact or os.environ.get(_DEFAULT_CONTACT_ENV) or None + return self._owner_email or None async def _pace(self) -> None: """Serializes every call through this client to >= _MIN_INTERVAL_SECS apart.""" diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index c880c75..cb8e01e 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -326,7 +326,13 @@ async def list_groups(state: dict) -> dict: "roots": roots.describe() if roots else [], "peers": sum(1 for p in peers.values() if p.get("group_id") == gid), }) - return {"groups": out} + roster = state.get("roster") + has_operator = False + if roster: + members = await roster.list_members() + has_operator = any(m["role"] == "operator" and m["status"] == "active" + for m in members) + return {"groups": out, "operator_paired": has_operator} async def attach_group(state: dict, name: str, shared_dir: str, @@ -783,22 +789,8 @@ async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict: # ── MusicBrainz config (Music app) ─────────────────────────────────────────── -async def set_musicbrainz_config(state: dict, contact: str | None = None) -> dict: - """ - The node-wide User-Agent contact string MusicBrainz's usage policy asks - for (docs/musicbay.md §3.2). Unlike set_tmdb_config there is no token to - manage — MusicBrainz's read endpoints need no credential — so this is a - single field. `contact=""` explicitly clears a previously-set contact - (reverting to "no calls at all", never a generic/unidentified - User-Agent); `contact=None` leaves whatever was there unchanged. - """ - roster = _roster(state) - await roster.set_musicbrainz_contact(contact, set_by=state.get("node_user_id", "")) - if contact is not None: - state["musicbrainz_contact_configured"] = bool(contact) - log.info("MusicBrainz config: contact_configured=%s", bool(contact)) - return {"contact_configured": state.get("musicbrainz_contact_configured", False)} - +# set_musicbrainz_config removed — MusicBrainz contact is now the owner's +# hub email, resolved at login (daemon.py / musicbrainz.py). async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) -> dict: """ diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index d6dc769..ff5282d 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -705,25 +705,8 @@ class Roster: await self.set_setting(group_id, self.SETTING_TMDB_ENABLED, "1" if enabled else "0", set_by) - # The Music app's MusicBrainz contact string (docs/musicbay.md §3.2) — - # node-wide, under the same group_id="" sentinel as the TMDB token/ - # language above, for the identical reason: one operator's User-Agent - # identity, not a per-group concern. Unlike TMDB there is no secret to - # store — this is the contact MusicBrainz's usage policy asks a client - # to identify itself with, not a credential. Unset means "no contact - # configured", which musicbrainz.py treats as "make no calls at all" - # (docs/musicbay.md §3.1) rather than sending an unidentified client. - SETTING_MUSICBRAINZ_CONTACT = "musicbrainz_contact" - - async def musicbrainz_contact(self) -> str | None: - return await self.get_setting( - self.NODE_WIDE_GROUP_ID, self.SETTING_MUSICBRAINZ_CONTACT) or None - - async def set_musicbrainz_contact(self, contact: str | None = None, set_by: str = "") -> None: - """`contact=""` clears it; `contact=None` leaves it unchanged (tmdb_config's shape).""" - if contact is not None: - await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_MUSICBRAINZ_CONTACT, - contact, set_by) + # MusicBrainz contact is now the node owner's hub email, resolved at + # login (musicbrainz.py) — no roster setting needed. # Whether MusicBrainz lookups run for this group at all — per-group from # the start (unlike tmdb_enabled, which started node-wide and moved diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index dfa775e..dd68e18 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -70,7 +70,6 @@ from meshbay_common.adminop import ( OP_TMDB_ENABLED, OP_VIDEO_ROOT, OP_TMDB_OVERRIDE, - OP_MUSICBRAINZ_CONFIG, OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, OP_PHOTO_ROOTS, @@ -476,8 +475,6 @@ class WebRTCPeerSession: self._spawn(self._do_tmdb_search_request(msg)) elif mtype == MNP.TMDB_OVERRIDE: self._do_tmdb_override(msg) - elif mtype == MNP.MUSICBRAINZ_CONFIG: - self._do_musicbrainz_config(msg) elif mtype == MNP.MUSICBRAINZ_ENABLED: self._do_musicbrainz_enabled(msg) elif mtype == MNP.MUSIC_META_REQ: @@ -753,8 +750,6 @@ class WebRTCPeerSession: # fields above. No language field: MusicBrainz search doesn't # take one the way TMDB does. "musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)), - "musicbrainz_contact_configured": bool( - self._ctx.get("daemon_state", {}).get("musicbrainz_contact_configured", False)), # Which folder the Music app treats as its entry point for this # group — same shape as video_root above, "" means unset (the # Music tab shows nothing yet). @@ -2069,59 +2064,6 @@ class WebRTCPeerSession: except Exception: pass - def _do_musicbrainz_config(self, msg: dict) -> None: - """ - Set (or clear) the node-wide MusicBrainz User-Agent contact string - (docs/musicbay.md §3.2). Unlike tmdb_config there is no token field — - MusicBrainz's read endpoints need no credential, only a descriptive - client identity. Signed like tmdb_config: this changes outbound - third-party network traffic the node did not have before the Music - app (§8) — an unsigned change would let any member alter egress the - operator never agreed to. - """ - contact = msg.get("contact") - if contact is not None and not isinstance(contact, str): - self._send({"type": "error", "detail": "Invalid 'contact'"}) - return - if not self._has_admin_authority(): - self._send({"type": "error", "detail": "No authorized key for this"}) - return - # Not a secret (unlike tmdb_config's token) — a contact address is - # meant to be visible to whoever receives it (MusicBrainz), but it is - # still not committed to the audit log's subject line as free text: - # the same "yes/no configured" shape as tmdb_config keeps the audit - # log itself free of a personal address. - subject = f"contact_configured={'yes' if contact else 'no'}" - self._issue_admin_challenge( - OP_MUSICBRAINZ_CONFIG, subject, - payload={"contact": contact}, group_id="") - - async def _admin_exec_musicbrainz_config( - self, pending: dict, transcript: bytes, sig: bytes, - ) -> None: - if not await self._verify_admin_sig(transcript, sig): - self._send({"type": "error", "detail": "Signature verification failed"}) - self._audit("admin_auth_failed", f"musicbrainz_config:{pending['subject']}") - return - p = pending.get("payload") or {} - try: - result = await self._run_op(ops.set_musicbrainz_config, p.get("contact")) - except ops.OpError as e: - self._send({"type": "error", "detail": e.message}) - return - self._audit("musicbrainz_config", pending["subject"]) - - notice = { - "type": MNP.MUSICBRAINZ_CONFIG_ACK, "v": MNP_VERSION, - "contact_configured": result["contact_configured"], - } - for gctx in self._ctx.get("groups", {}).values(): - for session in list(gctx.get("_peers", {}).values()): - try: - session._send(notice) - except Exception: - pass - def _do_musicbrainz_enabled(self, msg: dict) -> None: """ Whether MusicBrainz lookups run for this group at all. Per-group @@ -3859,9 +3801,6 @@ class WebRTCPeerSession: elif pending["op"] == OP_TMDB_OVERRIDE: self._spawn( self._admin_exec_tmdb_override(pending, transcript, sig_bytes)) - elif pending["op"] == OP_MUSICBRAINZ_CONFIG: - self._spawn( - self._admin_exec_musicbrainz_config(pending, transcript, sig_bytes)) elif pending["op"] == OP_MUSICBRAINZ_ENABLED: self._spawn( self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 3dc320b..dfd9f68 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -116,9 +116,31 @@ def create_ui_app(state: dict) -> FastAPI: total_files = sum(idx.count for idx in indexes.values()) groups_ctx = state.get("groups_ctx", {}) webrtc = state.get("webrtc") + status = state.get("status", "starting") + + needs = [] + if status == "waiting_for_node_key": + needs.append("node_key_link") + if status == "running" and not groups_ctx: + needs.append("group_add") + if status == "running": + roster = state.get("roster") + if roster: + from meshbay_node.roster import Roster + members = await roster.list_members() + operators = [m for m in members + if m["role"] == "operator" and m["status"] == "active"] + if not operators: + needs.append("operator_pair") + for gid, gctx in groups_ctx.items(): + if not gctx.get("gek"): + name = gctx.get("name", gid[:8]) + needs.append(f"gek_init:{name}") + return { "version": __version__, - "status": state.get("status", "starting"), + "status": status, + "needs": needs, "hub_url": state.get("hub_url", ""), "username": state.get("username", ""), "quic_port": state.get("quic_port", 0), @@ -129,6 +151,14 @@ def create_ui_app(state: dict) -> FastAPI: "pk_node_ed25519": state.get("pk_node_ed25519", ""), } + @app.delete("/api/unlink") + async def api_unlink(): + hub = state.get("hub") + if not hub: + raise HTTPException(status_code=503, detail="Hub not connected") + await hub.unlink_node_key() + return {"status": "unlinked"} + @app.get("/api/groups") async def api_groups(): return await _op(lambda: ops.list_groups(state)) |