diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui/app.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 318 |
1 files changed, 57 insertions, 261 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index b78f78d..050829e 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -23,6 +23,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ +from meshbay_node import ops from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_common.crypto import generate_gek, wrap_gek_aes from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR @@ -30,6 +31,24 @@ from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR log = logging.getLogger(__name__) +def _op(coro): + """ + Run an operation and translate its refusal into a JSON response. + + The operations live in `meshbay_node.ops` and know nothing about HTTP. This + is the whole of the HTTP adapter: without it each handler would carry its + own status codes, and the MNP handler in Stage B3 would carry a second set + that slowly stopped agreeing. + """ + async def run(): + try: + return await coro() + except ops.OpError as e: + return JSONResponse(e.as_dict(), e.status) + return run() + + + def create_ui_app(state: dict) -> FastAPI: app = FastAPI( title="MeshBay Node Admin", @@ -110,90 +129,27 @@ def create_ui_app(state: dict) -> FastAPI: @app.get("/api/groups") async def api_groups(): - groups_ctx = state.get("groups_ctx", {}) - config = state.get("config") - result = [] - for gid, ctx in groups_ctx.items(): - cfg = None - if config: - cfg = next((g for g in config.groups if g.id == gid), None) - idx = ctx.get("index") - result.append({ - "id": gid, - "name": cfg.name if cfg else gid[:8], - "shared_dir": str(ctx.get("shared_root", "")), - "visibility": cfg.visibility if cfg else "private", - "file_count": idx.count if idx else 0, - "index_version": idx.version if idx else 0, - }) - return {"groups": result} - + return await _op(lambda: ops.list_groups(state)) @app.post("/api/groups/attach") async def attach_group(payload: dict): - """ - Write a new [[groups]] block into node.toml. + return await _op(lambda: ops.attach_group( + state, + (payload.get("name") or "").strip(), + (payload.get("shared_dir") or "").strip(), + )) - The name-to-id lookup happens here because this process is the one logged - into the hub. Nothing is created on the hub: the group already exists, - this only tells the node to host it. - """ - name = (payload.get("name") or "").strip() - shared_dir = (payload.get("shared_dir") or "").strip() - if not name or not shared_dir: - return JSONResponse({"error": "name and shared_dir are required"}, 400) + @app.delete("/api/groups/{group_id}/files/{file_id}") + async def delete_file(group_id: str, file_id: str): + """Milestone 14.11 — the last operator action that needed a browser.""" + return await _op(lambda: ops.delete_file(state, group_id, file_id)) - config = state.get("config") - if not config: - return JSONResponse({"error": "No config loaded"}, 503) + @app.get("/api/denylist") + async def api_denylist(): + return await _op(lambda: ops.read_denylist(state)) - hub = state.get("hub") - if not hub or not hub._session: - return JSONResponse({"error": "Hub not connected"}, 503) - try: - mine = await hub.list_my_groups() - except Exception as e: - return JSONResponse({"error": f"Could not list groups: {e}"}, 502) - - match = [g for g in mine if g["id"] == name or g["name"] == name] - if not match: - return JSONResponse({ - "error": f"No group of yours is called {name!r}", - "available": [{"name": g["name"], "id": g["id"]} for g in mine], - }, 404) - if len(match) > 1: - return JSONResponse({ - "error": f"Several of your groups are called {name!r} — use the id", - "available": [{"name": g["name"], "id": g["id"]} for g in match], - }, 409) - group = match[0] - - if any(g.id == group["id"] for g in config.groups): - return JSONResponse( - {"error": f"{group['name']!r} is already hosted by this node"}, 409) - - path = Path(shared_dir).expanduser() - try: - path.mkdir(parents=True, exist_ok=True) - except OSError as e: - return JSONResponse({"error": f"Cannot create {path}: {e}"}, 400) - - # Appended as text rather than re-serialised: node.toml is hand-written - # and full of comments explaining decisions, and a round trip through a - # TOML writer would throw all of that away. - conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) - block = (f'\n[[groups]]\n' - f'id = "{group["id"]}"\n' - f'name = "{group["name"]}"\n' - f'shared_dir = "{path}"\n' - f'visibility = "{group.get("visibility", "private")}"\n') - try: - with conf_path.open("a") as f: - f.write(block) - except OSError as e: - return JSONResponse({"error": f"Cannot write {conf_path}: {e}"}, 500) - - return {"group_id": group["id"], "name": group["name"], - "shared_dir": str(path), "config": str(conf_path)} + @app.post("/api/denylist/clear") + async def api_denylist_clear(subject: str = ""): + return await _op(lambda: ops.clear_denylist(state, subject=subject)) @app.get("/api/groups/{group_id}/files") async def api_group_files(group_id: str): @@ -279,7 +235,11 @@ def create_ui_app(state: dict) -> FastAPI: { "id": g.id, "name": g.name, - "shared_dir": g.shared_dir, + "roots": [ + {"path": r.path, "name": r.name, "kind": r.kind, + "upload": r.upload} + for r in g.roots + ], "visibility": g.visibility, } for g in config.groups @@ -290,205 +250,33 @@ def create_ui_app(state: dict) -> FastAPI: @app.post("/api/operator/pair") async def operator_pair(): - """ - Issue a one-time code that pairs a browser as this node's operator. - - The code is the whole point: it binds the operator's browser identity key - to their account without asking the hub, which is what stops a hub from - naming itself node administrator (M3, and the same substitution as H3). - It is returned once and stored only as a hash. - """ - roster = state.get("roster") - user_id = state.get("node_user_id") - if not roster or not user_id: - return JSONResponse({"error": "Node not connected to hub yet"}, 503) - - config = state.get("config") - ttl = (config.node.pair_ttl_hours if config else 24) * 3600 - code = await roster.create_invite( - group_id="", # operator authority is node-wide - user_id=user_id, - role=ROLE_OPERATOR, - created_by="local-cli", - ttl=ttl, - username=(config.hub.username if config else ""), - ) - invites = await roster.list_invites() - expires = next((i["expires_at"] for i in invites - if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "") - return {"code": code, "expires_at": expires, "user_id": user_id} + return await _op(lambda: ops.pair_operator(state)) @app.get("/api/roster") async def api_roster(group_id: str = ""): - roster = state.get("roster") - if not roster: - return {"identities": [], "members": [], "invites": []} - return { - "identities": await roster.list_identities(), - "members": await roster.list_members(group_id or None), - "invites": await roster.list_invites(), - } + return await _op(lambda: ops.read_roster(state, group_id)) @app.post("/api/groups/{group_id}/invites") async def create_invite(group_id: str, username: str): - """ - Issue an invitation code from the CLI, without a browser. - - The hub is asked for the account id and nothing else — never for a key. - A hub that answered with the wrong account would produce an invite whose - code it never learns, since the code goes to a human out of band. - """ - roster = state.get("roster") - groups_ctx = state.get("groups_ctx", {}) - if not roster: - return JSONResponse({"error": "Roster not available"}, 503) - if group_id not in groups_ctx: - return JSONResponse({"error": "Group not hosted on this node"}, 404) - - hub = state.get("hub") - if not hub or not hub._session: - return JSONResponse({"error": "Hub not connected"}, 503) - try: - account = await hub.get_user_pubkeys(username) - except Exception as e: - return JSONResponse({"error": f"Unknown user {username!r}: {e}"}, 404) - - config = state.get("config") - ttl = (config.node.invite_ttl_hours if config else 168) * 3600 - code = await roster.create_invite( - group_id=group_id, - user_id=account["user_id"], - role=ROLE_MEMBER, - created_by="local-cli", - ttl=ttl, - username=username, - ) - invites = await roster.list_invites() - expires = next((i["expires_at"] for i in invites - if i["user_id"] == account["user_id"] - and i["group_id"] == group_id), "") - return {"code": code, "expires_at": expires, - "username": username, "user_id": account["user_id"]} + return await _op(lambda: ops.create_invite(state, group_id, username)) @app.get("/api/resolve") async def resolve_user(username: str): - """ - Map a username to an account id for the CLI. - - The roster answers first — it is the node's own record. The hub is the - fallback for identities pinned before invitations carried a name, and for - people admitted through an open-join group. Only an account id comes back; - no key is ever taken from here. - """ - roster = state.get("roster") - if roster: - for ident in await roster.list_identities(): - if ident["username"] == username: - return {"user_id": ident["user_id"], "source": "roster"} - hub = state.get("hub") - if hub and hub._session: - try: - account = await hub.get_user_pubkeys(username) - return {"user_id": account["user_id"], "source": "hub"} - except Exception: - pass - return JSONResponse({"error": f"Unknown user {username!r}"}, 404) + return await _op(lambda: ops.resolve_user(state, username)) @app.post("/api/members/{user_id}/revoke") async def revoke_member(user_id: str, group_id: str): - """ - Stop serving the group key to someone. - - Takes effect on their next connection: the key is wrapped on demand, so - there is no stored bundle left behind that would outlive this. Rotating - the group key is still required — they hold the current one. - """ - roster = state.get("roster") - if not roster: - return JSONResponse({"error": "Roster not available"}, 503) - if not await roster.set_status(group_id, user_id, "revoked"): - return JSONResponse({"error": "No such member in that group"}, 404) - log.info("Member revoked: user=%s group=%s", user_id[:8], group_id[:8]) - return {"status": "revoked", "user_id": user_id, "group_id": group_id, - "reminder": "rotate the group key: meshbay-node gek-init"} + return await _op(lambda: ops.revoke_member(state, user_id, group_id)) @app.post("/api/members/{user_id}/unpin") async def unpin_member(user_id: str): - """Forget a pinned identity, so the person can pair again with a new key.""" - roster = state.get("roster") - if not roster: - return JSONResponse({"error": "Roster not available"}, 503) - if not await roster.unpin(user_id): - return JSONResponse({"error": "No such pinned identity"}, 404) - log.info("Identity unpinned: user=%s", user_id[:8]) - return {"status": "unpinned", "user_id": user_id} + return await _op(lambda: ops.unpin_member(state, user_id)) # ── GEK initialization (operator only, localhost) ────────────────────── @app.post("/api/groups/{group_id}/gek") - async def init_gek(group_id: str): - """ - Generate the group key and activate it. - - It used to be wrapped here for every member, using public keys fetched from - the hub — which is H3 with the node as the victim instead of the inviter: a - hub answering with its own key was handed the group key by the node itself. - - Nothing is pre-wrapped for members now. Each member's copy is produced when - they connect, for a key they proved they hold (`join_request`). Only the - node's own copy is stored, so the daemon can reload the key across restarts - without the operator's browser. - """ - groups_ctx = state.get("groups_ctx", {}) - if group_id not in groups_ctx: - return JSONResponse({"error": "Group not hosted on this node"}, 404) - - hub = state.get("hub") - if not hub or not hub._session: - return JSONResponse({"error": "Hub not connected"}, 503) - - bundle_store = state.get("bundle_store") - if not bundle_store: - return JSONResponse({"error": "Bundle store not available"}, 503) - - existing_gek = groups_ctx[group_id].get("gek") - gek = existing_gek or generate_gek() - errors: list[str] = [] - - roster = state.get("roster") - authorized = len(await roster.list_members(group_id)) if roster else 0 - - # Store a copy wrapped for the node keystore X25519 key so the daemon can - # reload the GEK on restart without the operator's browser keys. - node_user_id = hub._session.user_id if hub._session else None - pk_x_node_raw = state.get("pk_x25519_raw") - if pk_x_node_raw and node_user_id: - try: - node_bundle = wrap_gek_aes(gek, pk_x_node_raw) - await bundle_store.store( - group_id, f"_node_{node_user_id}", - node_bundle["pk_eph_b64"], node_bundle["nonce_b64"], - node_bundle["wrapped_b64"], - ) - log.info("GEK wrapped for node keystore (daemon reload)") - except Exception as e: - errors.append(f"node keystore: {e}") - log.warning("Failed to wrap GEK for node keystore: %s", e) - - groups_ctx[group_id]["gek"] = gek - log.info("GEK initialized for group %s — %d authorized member(s) will " - "receive it on connect", group_id[:8], authorized) - - webrtc = state.get("webrtc") - if webrtc and "groups" in webrtc._ctx and group_id in webrtc._ctx["groups"]: - webrtc._ctx["groups"][group_id]["gek"] = gek - - return { - "status": "ok", - "group_id": group_id, - "authorized_members": authorized, - "errors": errors, - } + async def init_gek(group_id: str, rotate: bool = False): + return await _op(lambda: ops.set_gek(state, group_id, rotate=rotate)) # ── Chat endpoints ─────────────────────────────────────────────────────── @@ -656,7 +444,15 @@ def _render_page(state: dict, roster_view: dict | None = None) -> str: cfg = next((g for g in config.groups if g.id == gid), None) idx = ctx.get("index") name = cfg.name if cfg else gid[:8] - shared = ctx.get("shared_root", "") + roots = ctx.get("roots") + # An unavailable root is shown as such rather than hidden: its files are + # still listed and still in the index, and hiding the root would make a + # frozen library look deleted — the exact confusion this is meant to + # prevent. + shared = ", ".join( + f"{r.name} → {r.path}" + ("" if r.available else " [UNAVAILABLE]") + for r in roots + ) if roots else "" vis = cfg.visibility if cfg else "private" fcount = idx.count if idx else 0 total_size = sum(e.size for e in idx.entries) if idx else 0 |