diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-29 11:15:54 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-29 11:15:54 +0200 |
| commit | b7733e812fadd6007976d262bd1d793572a36ba7 (patch) | |
| tree | 52e2ac07c9ba4929fa50b6ba8d19f94f86e8b304 /packages/meshbay-node | |
| parent | 09a007937f03fad04a390323f3817f1ae7d7c2a9 (diff) | |
| download | meshbay-b7733e812fadd6007976d262bd1d793572a36ba7.tar.gz | |
feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contact
Wizard (Electron):
- Auto-provisions node config (hub URL + username) from logged-in user
- node:start handles both cold start and restart of misconfigured daemon
- Waits for daemon to reach 'running', auto-links node key on hub
- probeNode accepts intermediate states for wizard progress feedback
Reset (meshbay-node reset):
- Unlinks node key from hub (DELETE /me/node_key, best-effort)
- Stops and disables daemon (systemctl --user disable --now)
- Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay
MusicBrainz contact:
- Resolved from owner's hub email instead of per-node roster config
- Removed musicbrainz_contact UI and WebRTC handshake field
- Removed set_musicbrainz_contact/musicbrainz_contact from roster
Node pairing:
- Added operator pairing banner on NodePage
- Added operator_paired flag to list_groups
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/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 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_cli_dispatch.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_musicbrainz.py | 33 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_musicbrainz_config_policy.py | 179 |
11 files changed, 264 insertions, 343 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)) diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index d9edb17..aff7330 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -128,7 +128,7 @@ def test_the_verb_list_here_matches_the_parser(): exercised = {argv[0] for argv in VERBS} # `init` writes a config file and `calibrate-argon2` burns CPU for seconds; # both are excluded on purpose rather than by omission. - untested = declared - exercised - {"init", "calibrate-argon2"} + untested = declared - exercised - {"init", "reset", "calibrate-argon2"} assert not untested, ( f"CLI verbs with no dispatch test: {sorted(untested)} — add them to " f"VERBS above") diff --git a/packages/meshbay-node/tests/test_musicbrainz.py b/packages/meshbay-node/tests/test_musicbrainz.py index 4d075d3..843a691 100644 --- a/packages/meshbay-node/tests/test_musicbrainz.py +++ b/packages/meshbay-node/tests/test_musicbrainz.py @@ -7,14 +7,6 @@ import pytest from meshbay_node.musicbrainz import _MIN_INTERVAL_SECS, MusicBrainzClient, _escape_lucene -class FakeRoster: - def __init__(self, contact: str | None = "operator@example.invalid"): - self._contact = contact - - async def musicbrainz_contact(self): - return self._contact - - def _handler(response_map): def handle(request: httpx.Request) -> httpx.Response: path = request.url.path @@ -30,7 +22,7 @@ async def test_search_release_returns_top_result_and_confidence(): body = {"releases": [{"id": "abc-123", "title": "The Great Album", "artist-credit": [{"name": "Some Artist"}]}]} client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(_handler({"release": body})), ) result, ratio = await client.search_release("Some Artist", "The Great Album") @@ -44,7 +36,7 @@ async def test_search_release_returns_top_result_and_confidence(): @pytest.mark.asyncio async def test_no_results_returns_none_and_zero_confidence(): client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(_handler({"release": {"releases": []}})), ) result, ratio = await client.search_release("Nobody", "Nonexistent Obscure Album") @@ -80,7 +72,7 @@ async def test_strict_match_does_not_pay_for_a_second_request(): }) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) await client.search_release("Some Artist", "The Great Album") @@ -112,7 +104,7 @@ async def test_falls_back_to_a_loose_query_when_the_strict_one_finds_nothing(): }) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Groundation", "Hebron Gate (2003)") @@ -139,7 +131,7 @@ async def test_confidence_reflects_a_wrong_artist_on_a_same_titled_release(): }) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Groundation", "Live") @@ -150,8 +142,7 @@ async def test_confidence_reflects_a_wrong_artist_on_a_same_titled_release(): @pytest.mark.asyncio -async def test_no_contact_configured_makes_no_request(monkeypatch): - monkeypatch.delenv("MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT", raising=False) +async def test_no_contact_configured_makes_no_request(): calls = [] def handle(request: httpx.Request) -> httpx.Response: @@ -159,7 +150,7 @@ async def test_no_contact_configured_makes_no_request(monkeypatch): return httpx.Response(200, json={"releases": []}) client = MusicBrainzClient( - roster=FakeRoster(contact=None), + owner_email="", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Anyone", "Anything") @@ -178,7 +169,7 @@ async def test_the_configured_contact_is_sent_as_user_agent(): return httpx.Response(200, json={"releases": []}) client = MusicBrainzClient( - roster=FakeRoster(contact="operator@example.invalid"), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) await client.search_release("Anyone", "Anything") @@ -193,7 +184,7 @@ async def test_http_error_returns_none_gracefully(): return httpx.Response(500, json={"error": "server error"}) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Anyone", "Anything") @@ -209,7 +200,7 @@ async def test_cover_art_missing_returns_none_not_an_error(): return httpx.Response(404) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) content = await client.fetch_cover_art("abc-123") @@ -224,7 +215,7 @@ async def test_cover_art_found_returns_bytes(): return httpx.Response(200, content=b"\xff\xd8fake-jpeg-bytes") client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) content = await client.fetch_cover_art("abc-123") @@ -245,7 +236,7 @@ async def test_calls_are_paced_at_least_min_interval_apart(): return httpx.Response(200, json={"releases": []}) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) start = time.monotonic() diff --git a/packages/meshbay-node/tests/test_musicbrainz_config_policy.py b/packages/meshbay-node/tests/test_musicbrainz_config_policy.py deleted file mode 100644 index 3590f01..0000000 --- a/packages/meshbay-node/tests/test_musicbrainz_config_policy.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -The operator's MusicBrainz User-Agent contact string — docs/musicbay.md -§3.2/§6. Same shape as test_tmdb_config_policy.py: a signed operator -instruction, node-wide (group_id="") rather than per-group, stored via -roster.py's group_settings table. - -Unlike TMDB's token, a contact string is not a secret — MusicBrainz's usage -policy expects it to be visible to the service it's sent to — but the -subject signed/audited still only ever says whether one was configured -(never the address itself), the same "yes/no" shape as tmdb_config's -subject, to keep a personal contact out of the audit log as free text. -""" - -from pathlib import Path - -import pytest - -from meshbay_common.adminop import OP_MUSICBRAINZ_CONFIG -from meshbay_node.indexer.group_index import GroupIndex -from meshbay_node.roster import Roster -from meshbay_node.transport.webrtc_server import WebRTCPeerSession -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from conftest import one_root - -pytestmark = pytest.mark.asyncio - - -def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: - shared_root = tmp_path / "shared" - shared_root.mkdir(exist_ok=True) - index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) - ctx = { - "roots": one_root(shared_root), - "index": index, - "sk_node": index.sk_node, - "node_user_id": operator, - } - session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = ctx - session._group_id = None - session._user_id = user_id - session._pk_user = "" - session.sent = [] - session._send = session.sent.append - session._audit = lambda *a, **k: None - return session - - -def _fake_challenge(issued: list): - return lambda op, subject, payload=None, group_id=None: issued.append( - (op, subject, payload, group_id)) - - -# ── Refused before a challenge is even issued ─────────────────────────────── - -async def test_non_string_contact_is_refused(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({"contact": 12345}) - - assert not issued - assert [m for m in session.sent if m.get("type") == "error"] - - -async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): - session = _session(tmp_path, "member-1", operator="the-operator") - session._has_admin_authority = lambda: False - - session._do_musicbrainz_config({"contact": "https://example.invalid/contact"}) - - assert [m for m in session.sent if m.get("type") == "error"] - - -# ── Who may change it, and what gets signed ───────────────────────────────── - -async def test_changing_it_needs_a_signature(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({}) - - assert len(issued) == 1 - op, subject, payload, group_id = issued[0] - assert op == OP_MUSICBRAINZ_CONFIG - assert group_id == "", "node-wide, like tmdb_config — not tied to self._group_id" - - -async def test_the_contact_itself_never_appears_in_the_signed_subject(tmp_path): - """ - Not a secret the way a TMDB token is, but still kept out of the audited - subject line as free text — same "yes/no configured" shape. - """ - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - contact = "operator@example.invalid" - session._do_musicbrainz_config({"contact": contact}) - - _, subject, payload, _ = issued[0] - assert contact not in subject - assert payload["contact"] == contact, "the real value still has to reach the exec step somehow" - - -async def test_subject_reflects_whether_a_contact_was_supplied(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({"contact": "x"}) - - _, subject, _, _ = issued[0] - assert subject == "contact_configured=yes" - - -async def test_subject_says_no_contact_when_none_given(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({}) - - _, subject, _, _ = issued[0] - assert subject == "contact_configured=no" - - -# ── Where it is stored ────────────────────────────────────────────────────── - -async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - assert await roster.musicbrainz_contact() is None, \ - "absent must mean 'no contact configured' — no shipped default to fall back to" - await roster.set_musicbrainz_contact("operator@example.invalid", set_by="op") - assert await roster.musicbrainz_contact() == "operator@example.invalid" - finally: - await roster.close() - - reopened = Roster(db_path=tmp_path / "roster.db") - await reopened.open() - try: - assert await reopened.musicbrainz_contact() == "operator@example.invalid" - finally: - await reopened.close() - - -async def test_clearing_the_contact_reverts_to_unconfigured(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - await roster.set_musicbrainz_contact("a-contact", set_by="op") - assert await roster.musicbrainz_contact() == "a-contact" - - await roster.set_musicbrainz_contact("", set_by="op") - assert await roster.musicbrainz_contact() is None, \ - "an explicit empty string clears the contact" - finally: - await roster.close() - - -async def test_omitting_the_contact_leaves_it_unchanged(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - await roster.set_musicbrainz_contact("a-contact", set_by="op") - await roster.set_musicbrainz_contact(None, set_by="op") - assert await roster.musicbrainz_contact() == "a-contact" - finally: - await roster.close() |