aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py65
-rw-r--r--packages/meshbay-node/tests/test_apps_enabled_policy.py137
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()