summaryrefslogtreecommitdiffstats
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.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py20
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py18
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py43
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py5
-rw-r--r--packages/meshbay-node/tests/test_search_listed.py194
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