diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 65 |
1 files changed, 65 insertions, 0 deletions
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 947d1f9..b6f572a 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -62,6 +62,7 @@ from meshbay_common.adminop import ( OP_GEK_ROTATE, OP_MEMBER_UNPIN, OP_MEMBER_UPLOAD, + OP_APPS_ENABLED, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -406,6 +407,8 @@ class WebRTCPeerSession: self._spawn(self._do_device_revoke(msg)) elif mtype == MNP.MEMBER_UPLOAD: self._do_member_upload(msg) + elif mtype == MNP.APPS_ENABLED: + self._do_apps_enabled(msg) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -651,6 +654,11 @@ class WebRTCPeerSession: # permission — the node refuses regardless — but without it the # only way to discover the answer is to try. "member_upload": bool(self._group_ctx().get("member_upload", True)), + # Which group "applications" to show. Absent/empty falls back to + # every registered one client-side, so a node that predates this + # setting (or one whose context has not loaded it yet) hides + # nothing. + "enabled_apps": list(self._group_ctx().get("enabled_apps") or []), } if node_user_id: ack["node_user_id"] = node_user_id @@ -1586,6 +1594,60 @@ class WebRTCPeerSession: except Exception: pass + # Every "application" a group can show — Chat and Files today. Videos, + # Music, Photos join this set (and apps.js's registry, client-side) when + # they land; nothing else about this handler changes. + ALLOWED_APPS = frozenset({"chat", "files"}) + + def _do_apps_enabled(self, msg: dict) -> None: + """ + Turn a group "application" on or off for everyone, for this group. + + Signed like `member_upload`: this decides what a member sees, and an + unsigned message would let any member turn a disabled one back on. + """ + apps = msg.get("apps") + if not isinstance(apps, list) or not apps: + self._send({"type": "error", "detail": "Missing or empty apps"}) + return + unknown = set(apps) - self.ALLOWED_APPS + if unknown: + self._send({"type": "error", + "detail": f"Unknown app(s): {', '.join(sorted(unknown))}"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # The subject is what the operator is shown before signing, and what + # the client compares its own request against (transport.js) — a + # canonical form so both sides build the same transcript. + self._issue_admin_challenge(OP_APPS_ENABLED, ",".join(sorted(apps))) + + async def _admin_exec_apps_enabled( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + apps = pending["subject"].split(",") if pending["subject"] else [] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"apps_enabled:{pending['subject']}") + return + try: + await self._run_op( + ops.set_enabled_apps, self._group_id or "", apps) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("apps_enabled", pending["subject"]) + + # Everyone already connected is told, so a disabled tab disappears + # without waiting for a reconnection. + notice = {"type": MNP.APPS_ENABLED_ACK, "v": MNP_VERSION, "apps": apps} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + # ── Node management (D5) ───────────────────────────────────────────────── async def _do_node_status(self, msg: dict) -> None: @@ -2561,6 +2623,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_MEMBER_UPLOAD: self._spawn( self._admin_exec_member_upload(pending, transcript, sig_bytes)) + elif pending["op"] == OP_APPS_ENABLED: + self._spawn( + self._admin_exec_apps_enabled(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) |