From cd2745cecff12e894e0dfa702bff6a90f0e8734e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 14 Sep 2026 21:45:53 +0200 Subject: feat: a group can be left out of Search, and Search tries every node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `search_listed` is a per-group setting on the node, changed by a signed operator op and carried in the sealed handshake ack. Search reads it after the handshake and stops there: no index is fetched, cached or merged, in any of the four views, and the page says how many groups it left out. The switch is a "Search" section in the group's settings, shown to the operator. Absent means listed, at every layer: roster default, ack default, and the client only drops a group on an explicit `false` — so an upgrade or an older node removes nothing from anyone's Search. It is a listing preference and protects nothing: the node serves the same index to Search and to the group page and cannot tell them apart, every member lists the group by opening it, and a client that ignores the flag lists it in Search too. Design §9.11 says so, so it is never described as private. The cost is one handshake per unlisted group, because only the node knows the setting. Search also took `nodes[0]` twice — for the index and for the pooled connection — the defect 4cce50f fixed on the group page only. One `connectToGroup` now walks the list the same way: a refusal about this browser stops, `not_hosted` or a failed connection moves on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm --- packages/meshbay-node/src/meshbay_node/daemon.py | 6 + packages/meshbay-node/src/meshbay_node/ops.py | 20 +++ packages/meshbay-node/src/meshbay_node/roster.py | 18 ++ .../src/meshbay_node/transport/webrtc_server.py | 43 +++++ packages/meshbay-node/src/meshbay_node/ui/app.py | 5 + packages/meshbay-node/tests/test_search_listed.py | 194 +++++++++++++++++++++ 6 files changed, 286 insertions(+) create mode 100644 packages/meshbay-node/tests/test_search_listed.py (limited to 'packages/meshbay-node') 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 -- cgit v1.2.3