diff options
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 6 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 20 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 18 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 43 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_search_listed.py | 194 |
6 files changed, 286 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index a43e3a3..7646c5d 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -431,6 +431,9 @@ class NodeDaemon: # Whether the node unfurls links members post here. "chat_link_preview": await self._roster.chat_link_preview( group_cfg.id) if self._roster else True, + # Whether members' cross-group Search lists this group. + "search_listed": await self._roster.search_listed( + group_cfg.id) if self._roster else True, # Which chat epoch key is current. Opened here if the # group has none, because chat is always encrypted (MNP # 2.0) and a group with no epoch is a group nobody can @@ -925,6 +928,9 @@ class NodeDaemon: "chat_link_preview": ( await self._roster.chat_link_preview(group_cfg.id) if self._roster else True), + "search_listed": ( + await self._roster.search_listed(group_cfg.id) + if self._roster else True), "chat_epoch": await self._ensure_chat_epoch(group_cfg.id), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 91a922a..8cd893c 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -1733,6 +1733,26 @@ async def set_chat_link_preview(state: dict, group_id: str, return {"enabled": enabled, "group_id": group_id} +async def set_search_listed(state: dict, group_id: str, listed: bool) -> dict: + """ + Whether this group's files appear in members' cross-group Search. + + A presentation choice, and it must never be described as more: a member + still lists the whole group by opening it, the node serves the index + exactly as before, and a client that ignores the flag lists the group in + Search too. What it buys is a family album not turning up in the middle of + a film library. Absent means listed. + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_search_listed(group_id, listed, + set_by=state.get("node_user_id", "")) + ctx["search_listed"] = listed + log.info("Search listing for group %s: %s", group_id[:8], + "on" if listed else "off") + return {"listed": listed, "group_id": group_id} + + # ── Scan settings ──────────────────────────────────────────────────────────── async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: float, diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index b7fbf8e..b0ca78b 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -889,6 +889,24 @@ class Roster: "1" if enabled else "0", set_by) return enabled + # ── Search ────────────────────────────────────────────────────────────── + + # Whether this group's files appear in members' cross-group Search. Not an + # access control: a member lists the group by opening it, and a client that + # ignores this lists it in Search too. Unset means listed, because that is + # what every group did before this existed. + SETTING_SEARCH_LISTED = "search_listed" + + async def search_listed(self, group_id: str) -> bool: + value = await self.get_setting(group_id, self.SETTING_SEARCH_LISTED, "1") + return value != "0" + + async def set_search_listed(self, group_id: str, listed: bool, + set_by: str = "") -> bool: + await self.set_setting(group_id, self.SETTING_SEARCH_LISTED, + "1" if listed else "0", set_by) + return listed + # Whether TMDB lookups run for this group at all — per-group, unlike the # token/language above: one node process can share a real media library # group and several test/demo groups, and outbound TMDB traffic (and API 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 79a97cc..af41061 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -79,6 +79,7 @@ from meshbay_common.adminop import ( OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW, + OP_SEARCH_LISTED, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_ROOT_UPDATE, @@ -623,6 +624,8 @@ class WebRTCPeerSession: self._do_chat_directory(msg) elif mtype == MNP.CHAT_LINK_PREVIEW: self._do_chat_link_preview(msg) + elif mtype == MNP.SEARCH_LISTED: + self._do_search_listed(msg) elif mtype == MNP.CHAT_EPOCH: self._do_chat_epoch(msg) elif mtype == MNP.CHAT_KEYS_REQ: @@ -949,6 +952,11 @@ class WebRTCPeerSession: # on, which is what it did before this existed. "chat_link_preview": bool( self._group_ctx().get("chat_link_preview", True)), + # Whether the reader's cross-group Search should list this group. + # Presentation only: the index below is served to Search and to the + # group page alike, and this cannot tell them apart. Sealed like the + # rest, so the hub cannot flip it. Absent means listed. + "search_listed": bool(self._group_ctx().get("search_listed", True)), # Which chat epoch key a client should be sealing under. Inside # the sealed part of the ack like every other configuration field, # so it carries an authentication tag from a key the hub does not @@ -2392,6 +2400,38 @@ class WebRTCPeerSession: self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK, "v": MNP_VERSION, "enabled": enabled}) + def _do_search_listed(self, msg: dict) -> None: + """ + Whether this group's files appear in members' cross-group Search. + Signed because it changes what every member's Search shows, not + because it protects anything — see ops.set_search_listed. + """ + listed = msg.get("listed") + if not isinstance(listed, bool): + self._send({"type": "error", "detail": "Missing or invalid 'listed'"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_SEARCH_LISTED, "on" if listed else "off") + + async def _admin_exec_search_listed( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + listed = pending["subject"] == "on" + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"search_listed:{pending['subject']}") + return + try: + await self._run_op(ops.set_search_listed, self._group_id or "", listed) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("search_listed", pending["subject"]) + self._broadcast_to_group({"type": MNP.SEARCH_LISTED_ACK, + "v": MNP_VERSION, "listed": listed}) + def _do_chat_epoch(self, msg: dict) -> None: """ Open a new chat epoch by hand. Operator only, and signed. @@ -5446,6 +5486,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_CHAT_LINK_PREVIEW: self._spawn( self._admin_exec_chat_link_preview(pending, transcript, sig_bytes)) + elif pending["op"] == OP_SEARCH_LISTED: + self._spawn( + self._admin_exec_search_listed(pending, transcript, sig_bytes)) elif pending["op"] == OP_CHAT_EPOCH: self._spawn( self._admin_exec_chat_epoch(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 22b5afb..96fa137 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -506,6 +506,11 @@ def create_ui_app(state: dict) -> FastAPI: return await _op(lambda: ops.set_chat_link_preview( state, group_id, bool(payload.get("enabled", True)))) + @app.put("/api/groups/{group_id}/search-listed") + async def set_search_listed(group_id: str, payload: dict): + return await _op(lambda: ops.set_search_listed( + state, group_id, bool(payload.get("listed", True)))) + # ── Scan settings (operator only, localhost) ────────────────────────── @app.put("/api/groups/{group_id}/scan-settings") diff --git a/packages/meshbay-node/tests/test_search_listed.py b/packages/meshbay-node/tests/test_search_listed.py new file mode 100644 index 0000000..412d743 --- /dev/null +++ b/packages/meshbay-node/tests/test_search_listed.py @@ -0,0 +1,194 @@ +""" +Whether a group's files are listed in members' cross-group Search. + +A presentation preference, stated as such everywhere (design §9.11): the node +serves the same index to Search and to the group page, and cannot tell them +apart. What is held here is the node half — stored on the node, absent means +listed, changed only by a signed operator instruction, kept in step with the +live context, and broadcast so every connected member's page moves with it. +""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.adminop import OP_SEARCH_LISTED +from meshbay_common.protocol import MNP +from meshbay_node import ops +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +GROUP = "g" * 32 + + +def _session(user_id: str = "op") -> WebRTCPeerSession: + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"node_user_id": "op"} + session._group_id = GROUP + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session.audited = [] + session._audit = lambda *a, **k: session.audited.append(a) + return session + + +def _challenges(session: WebRTCPeerSession) -> list: + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + return issued + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_absent_means_listed_and_the_setting_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.search_listed(GROUP) is True, ( + "absent must mean listed — an upgrade must not empty anyone's Search") + await roster.set_search_listed(GROUP, False, set_by="op") + assert await roster.search_listed(GROUP) is False + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.search_listed(GROUP) is False + assert await reopened.search_listed("other") is True, ( + "one group's setting must not answer for another") + finally: + await reopened.close() + + +async def test_the_op_updates_the_live_context(tmp_path): + """The handshake ack reads the context, so the op keeps the two in step.""" + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + state = {"roster": roster, "node_user_id": "op", + "groups_ctx": {GROUP: {"index": index}}} + out = await ops.set_search_listed(state, GROUP, False) + assert out == {"listed": False, "group_id": GROUP} + assert state["groups_ctx"][GROUP]["search_listed"] is False + assert await roster.search_listed(GROUP) is False + finally: + await roster.close() + + +async def test_the_op_refuses_a_group_this_node_does_not_host(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + state = {"roster": roster, "groups_ctx": {}} + with pytest.raises(ops.OpError): + await ops.set_search_listed(state, GROUP, False) + finally: + await roster.close() + + +# ── Signed, and refused before a challenge otherwise ──────────────────────── + +@pytest.mark.parametrize("payload", [{}, {"listed": "no"}, {"listed": 0}]) +async def test_a_malformed_request_is_refused(payload): + session = _session() + session._has_admin_authority = lambda: True + issued = _challenges(session) + + session._do_search_listed(payload) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_member_without_authority_is_refused(): + session = _session("member-1") + session._has_admin_authority = lambda: False + issued = _challenges(session) + + session._do_search_listed({"listed": False}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +@pytest.mark.parametrize("listed,subject", [(True, "on"), (False, "off")]) +async def test_the_subject_names_the_outcome(listed, subject): + session = _session() + session._has_admin_authority = lambda: True + issued = _challenges(session) + + session._do_search_listed({"listed": listed}) + + assert issued == [(OP_SEARCH_LISTED, subject)] + + +async def test_a_bad_signature_changes_nothing(): + session = _session() + ran = [] + + async def refuse(transcript, sig): + return False + + async def run_op(fn, *args): + ran.append(args) + + session._verify_admin_sig = refuse + session._run_op = run_op + session._broadcast_to_group = lambda notice: ran.append(notice) + + await session._admin_exec_search_listed( + {"subject": "off"}, b"transcript", b"sig") + + assert not ran + assert [m for m in session.sent if m.get("type") == "error"] + + +def test_the_handshake_ack_carries_it_sealed_and_absent_reads_as_listed(): + """ + Read off the real builder rather than a hand-made config: the ack's + configuration is the dict `_complete_handshake` seals, and a client that + connects after the operator changed the setting learns it from there. + """ + import ast + import inspect + import textwrap + + source = textwrap.dedent(inspect.getsource(WebRTCPeerSession._complete_handshake)) + tree = ast.parse(source) + config = next( + n.value for n in ast.walk(tree) + if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "config" for t in n.targets)) + assert isinstance(config, ast.Dict) + values = {k.value: v for k, v in zip(config.keys, config.values) + if isinstance(k, ast.Constant)} + assert "search_listed" in values, "the sealed ack no longer carries search_listed" + expr = ast.unparse(values["search_listed"]) + assert "'search_listed', True" in expr, ( + f"search_listed is built as {expr} — absent must read as listed") + + +async def test_a_signed_change_is_applied_and_broadcast(): + session = _session() + applied, broadcast = [], [] + + async def accept(transcript, sig): + return True + + async def run_op(fn, *args): + applied.append((fn, args)) + + session._verify_admin_sig = accept + session._run_op = run_op + session._broadcast_to_group = broadcast.append + + await session._admin_exec_search_listed( + {"subject": "off"}, b"transcript", b"sig") + + assert applied == [(ops.set_search_listed, (GROUP, False))] + assert len(broadcast) == 1 + assert broadcast[0]["type"] == MNP.SEARCH_LISTED_ACK + assert broadcast[0]["listed"] is False |