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 | |
| 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')
5 files changed, 250 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index b34e710..c68f999 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -264,6 +264,10 @@ class NodeDaemon: # place, so the two never drift within a run. "member_upload": await self._roster.member_upload_allowed( group_cfg.id) if self._roster else True, + # Same reasoning: read once at load, kept current in place + # by the signed operation that changes it. + "enabled_apps": await self._roster.enabled_apps( + group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS), } if not groups_ctx: @@ -584,6 +588,9 @@ class NodeDaemon: "member_upload": ( await self._roster.member_upload_allowed(group_cfg.id) if self._roster else True), + "enabled_apps": ( + await self._roster.enabled_apps(group_cfg.id) + if self._roster else list(Roster.DEFAULT_APPS)), "chat_store": store, } groups_ctx[group_cfg.id] = new_ctx diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index daddf17..c9d862a 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -711,6 +711,25 @@ async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict: return {"allowed": allowed, "group_id": group_id} +# ── Applications ───────────────────────────────────────────────────────────── + +async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: + """ + Which group "applications" (Chat, Files, ...) are shown to members. + + Same shape as `set_member_upload`: lives on the node (roster.db), takes + effect without a restart, and is signed by the operator (webrtc_server.py + checks the caller's own admin-authority allow-list before this runs). + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_enabled_apps(group_id, apps, + set_by=state.get("node_user_id", "")) + ctx["enabled_apps"] = apps + log.info("Enabled apps for group %s: %s", group_id[:8], ",".join(sorted(apps))) + return {"apps": apps, "group_id": group_id} + + # ── Reload ────────────────────────────────────────────────────────────────── async def reload_config(state: dict) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 9a52b53..1cc8cae 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -22,6 +22,7 @@ The code is what binds a public key to an account without asking the hub from __future__ import annotations import hashlib +import json import logging import os import secrets @@ -577,6 +578,27 @@ class Roster: "1" if allowed else "0", set_by) return allowed + # Which group "applications" (Chat, Files, and whatever registers later in + # apps.js) are shown to members. Unset means every app that exists — an + # existing group's tabs must not disappear because a node was upgraded. + SETTING_ENABLED_APPS = "enabled_apps" + DEFAULT_APPS = ("chat", "files") + + async def enabled_apps(self, group_id: str) -> list[str]: + value = await self.get_setting(group_id, self.SETTING_ENABLED_APPS) + if value is None: + return list(self.DEFAULT_APPS) + try: + return list(json.loads(value)) + except (ValueError, TypeError): + return list(self.DEFAULT_APPS) + + async def set_enabled_apps(self, group_id: str, apps: list[str], + set_by: str = "") -> list[str]: + await self.set_setting(group_id, self.SETTING_ENABLED_APPS, + json.dumps(sorted(apps)), set_by) + return apps + async def create_invite( self, group_id: str, 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)) diff --git a/packages/meshbay-node/tests/test_apps_enabled_policy.py b/packages/meshbay-node/tests/test_apps_enabled_policy.py new file mode 100644 index 0000000..671005a --- /dev/null +++ b/packages/meshbay-node/tests/test_apps_enabled_policy.py @@ -0,0 +1,137 @@ +""" +The operator decides which group "applications" (Chat, Files, ...) are shown. + +Same shape as `test_member_upload_policy.py`, because it is the same kind of +setting: changed by a signed operator instruction, stored on the node rather +than the hub, and safe for an existing group to have never heard of. The two +things specific to this one: the whole set is signed in one message rather +than one op per app, and an empty or unrecognised set is refused before a +challenge is ever issued — there is no file write to refuse afterwards the +way an unsigned upload is refused, so the check has to happen up front. +""" + +from pathlib import Path + +import pytest + +from meshbay_common.adminop import OP_APPS_ENABLED +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 + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_an_empty_set_is_refused(tmp_path): + """Never let the operator lock a group down to nothing — no round trip + to the operator's browser needed to learn that.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": []}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_an_unknown_app_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": ["chat", "videos"]}) + + assert not issued, "videos is not registered yet — accepting it would " \ + "silently store a setting no client can act on" + 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_apps_enabled({"apps": ["chat"]}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Who may change it ─────────────────────────────────────────────────────── + +async def test_changing_it_needs_a_signature(tmp_path): + """The request only ever produces a challenge. Nothing is applied until a + signature over the transcript verifies — the same path as member_upload.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": ["files"]}) + + assert issued == [(OP_APPS_ENABLED, "files")] + + +async def test_the_subject_is_the_sorted_set_so_both_sides_build_the_same_transcript(tmp_path): + """The operator's browser and the node must independently arrive at the + same subject string to sign/verify — order in the request must not + matter.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": ["files", "chat"]}) + + assert issued == [(OP_APPS_ENABLED, "chat,files")] + + +# ── 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 sorted(await roster.enabled_apps("g1")) == ["chat", "files"], ( + "absent must mean every registered app, or an upgrade hides one " + "for every existing group") + await roster.set_enabled_apps("g1", ["chat"], set_by="op") + assert await roster.enabled_apps("g1") == ["chat"] + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.enabled_apps("g1") == ["chat"] + assert sorted(await reopened.enabled_apps("g2")) == ["chat", "files"], ( + "one group's setting must not answer for another") + finally: + await reopened.close() |