aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ui/app.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui/app.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py110
1 files changed, 64 insertions, 46 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 21e4445..e671b72 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -24,6 +24,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
from meshbay_common.crypto import generate_gek, wrap_gek_aes
+from meshbay_common.join import ROLE_OPERATOR
log = logging.getLogger(__name__)
@@ -217,11 +218,61 @@ def create_ui_app(state: dict) -> FastAPI:
],
}
+ # ── Operator pairing (localhost only) ──────────────────────────────────
+
+ @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)
+
+ code = await roster.create_invite(
+ group_id="", # operator authority is node-wide
+ user_id=user_id,
+ role=ROLE_OPERATOR,
+ created_by="local-cli",
+ )
+ 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}
+
+ @app.get("/api/roster")
+ async def api_roster():
+ roster = state.get("roster")
+ if not roster:
+ return {"identities": [], "members": [], "pending_invites": 0}
+ return {
+ "identities": await roster.list_identities(),
+ "members": await roster.list_members(),
+ "pending_invites": len(await roster.list_invites()),
+ }
+
# ── GEK initialization (operator only, localhost) ──────────────────────
@app.post("/api/groups/{group_id}/gek")
async def init_gek(group_id: str):
- """Generate GEK, wrap for all group members, store, and activate."""
+ """
+ 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)
@@ -234,48 +285,15 @@ def create_ui_app(state: dict) -> FastAPI:
if not bundle_store:
return JSONResponse({"error": "Bundle store not available"}, 503)
- await hub.ensure_fresh_token()
- session = hub._session
- members_resp = await hub._http.get(
- f"/v1/groups/{group_id}/members",
- headers=session.auth_headers,
- )
- if not members_resp.is_success:
- return JSONResponse(
- {"error": f"Failed to fetch members: {members_resp.status_code}"}, 502)
- members = members_resp.json().get("members", [])
- if not members:
- return JSONResponse({"error": "No members in group"}, 400)
-
existing_gek = groups_ctx[group_id].get("gek")
gek = existing_gek or generate_gek()
+ errors: list[str] = []
- wrapped_count = 0
- errors = []
- for member in members:
- username = member["username"]
- user_id = member["user_id"]
- try:
- pk_data = await hub.get_user_pubkeys(username)
- pk_x_raw = base64.b64decode(pk_data["pk_x25519"])
- bundle = wrap_gek_aes(gek, pk_x_raw)
- await bundle_store.store(
- group_id, user_id,
- bundle["pk_eph_b64"], bundle["nonce_b64"], bundle["wrapped_b64"],
- )
- wrapped_count += 1
- log.info("GEK wrapped for %s (%s)", username, user_id[:8])
- except Exception as e:
- errors.append(f"{username}: {e}")
- log.warning("Failed to wrap GEK for %s: %s", username, e)
+ roster = state.get("roster")
+ authorized = len(await roster.list_members(group_id)) if roster else 0
- if wrapped_count == 0:
- return JSONResponse(
- {"error": "Failed to wrap GEK for any member", "details": errors}, 500)
-
- # Also store a copy wrapped for the node keystore X25519 key
- # so the daemon can reload GEK on restart without the operator's browser keys
- config = state.get("config")
+ # 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:
@@ -286,13 +304,14 @@ def create_ui_app(state: dict) -> FastAPI:
node_bundle["pk_eph_b64"], node_bundle["nonce_b64"],
node_bundle["wrapped_b64"],
)
- log.info("GEK also wrapped for node keystore (daemon reload)")
+ 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 — wrapped for %d/%d members",
- group_id[:8], wrapped_count, len(members))
+ 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"]:
@@ -301,8 +320,7 @@ def create_ui_app(state: dict) -> FastAPI:
return {
"status": "ok",
"group_id": group_id,
- "wrapped_count": wrapped_count,
- "total_members": len(members),
+ "authorized_members": authorized,
"errors": errors,
}
@@ -585,8 +603,8 @@ async function initGEK(groupId) {{
const resp = await fetch('/api/groups/' + groupId + '/gek?t=' + TOKEN, {{ method: 'POST' }});
const data = await resp.json();
if (resp.ok) {{
- if (status) status.textContent = 'GEK initialized — wrapped for '
- + data.wrapped_count + '/' + data.total_members + ' members';
+ if (status) status.textContent = 'GEK initialized — '
+ + data.authorized_members + ' authorized member(s) get it on connect';
if (status) status.style.color = '#22c55e';
setTimeout(() => location.reload(), 2000);
}} else {{