summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-14 21:45:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-14 21:45:53 +0200
commitcd2745cecff12e894e0dfa702bff6a90f0e8734e (patch)
tree8e864603cfd4c49cde535c8c4f96c5151ed4276c /packages/meshbay-node/src
parentdf3b808792daa745b5b0d5b9896ddca8849fe8b1 (diff)
downloadmeshbay-cd2745cecff12e894e0dfa702bff6a90f0e8734e.tar.gz
feat: a group can be left out of Search, and Search tries every node
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
Diffstat (limited to 'packages/meshbay-node/src')
-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
5 files changed, 92 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")