summaryrefslogtreecommitdiffstats
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
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
-rw-r--r--docs/MESHBAY_DESIGN.md14
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md3
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js39
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js117
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js26
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py5
-rw-r--r--packages/meshbay-hub/tests/test_search_unlisted.py96
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py28
-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
27 files changed, 620 insertions, 48 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 86b8e8f..6053bd8 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2348,6 +2348,20 @@ Two rules for a new application here:
group is a top-level folder, and merging would remove a file from one of them. A
test refuses a build that changes this.
+**A group can be left out of Search** — `search_listed`, a per-group setting on the
+node, signed by the operator and carried in the sealed ack. Search reads it after the
+handshake and stops there: no index is asked for, cached or merged, in any of the four
+views. The case it exists for is a family album that should not turn up in the middle
+of a film library.
+
+> **It is a listing preference and it protects nothing, against anyone.** The node
+> cannot tell Search's request from the group page's and serves the same index to
+> both; every member lists the whole group by opening it; a client that ignores the
+> flag lists the group in Search too. It must never be described as "private" or
+> "confidential". What it costs is one handshake per unlisted group, because only the
+> node knows the setting — a hub-side flag would save that and put group state on the
+> hub (§1.3).
+
One consequence is deliberate and is not a bug: albums are keyed by directory, so
two groups whose roots have *different* basenames put the same photo into two
differently-named albums, and the merge — scoped to a unit — leaves it in both. They
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 81605c6..f3fcbb9 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -532,6 +532,7 @@ at all — including a decryption.
| `<app>_directories` | each application's entry-point folders, keyed by the app's own registry name (`video`, `music`, `photo`, `chat`), **always a list**. This is the only form. The scalar `video_root` / `audio_root` / `photo_roots` fields that used to sit beside it are gone: one folder was never the general case, and two shapes for one answer meant whichever the reader consulted first decided it |
| `chat_directory` | where chat attachments are written. Singular because Chat genuinely has one destination; `""` means the operator has not chosen |
| `chat_link_preview` | whether the node unfurls links posted here. Absent means on |
+| `search_listed` | whether the reader's cross-group Search lists this group. Presentation only — the index is served identically either way. Absent means listed |
| `chat_epoch` | the chat epoch a client must seal under right now (§11.7). There is no `chat_encrypted` beside it, because there is no switch |
| `transfer_limits` | `{download, upload}` — this member's own caps in this group, so the interface can say "2 of your 2 slots are busy" instead of drawing a bare spinner. Absent reads as "no limit known" and the hint is not drawn; never as "unlimited", which would have the interface contradicting the node (§11.2) |
| `tmdb_enabled`, `musicbrainz_enabled` | per-group metadata lookups |
@@ -1000,6 +1001,7 @@ broadcast, every connected peer in the group learns the change without reconnect
| `app_directories` | `<app>:<dir>,<dir>,...` | operator | `app_directories_ack{app, dirs}` | yes |
| `chat_directory` | the path | operator | `chat_directory_ack{path}` | yes |
| `chat_link_preview` | `on\|off` | operator | `chat_link_preview_ack{enabled}` | yes |
+| `search_listed` | `on\|off` | operator | `search_listed_ack{listed}` | yes |
| `transfer_limits` | `d=<n>,u=<n>` | operator | `transfer_limits_ack{limits}` | yes |
| `chat_epoch` | `group_id` | operator | `chat_epoch_ack{epoch}` | yes |
| `group_attach`, `group_detach` | `group_id` | operator | `group_attach_ack` / `group_detach_ack` | no |
@@ -1879,6 +1881,7 @@ it back (§3.5).
| `app_directories` / `_ack` | C→N / N⇒C | signed | one application's folders, keyed by app name |
| `chat_directory` / `_ack` | C→N / N⇒C | signed | where chat attachments are written |
| `chat_link_preview` / `_ack` | C→N / N⇒C | signed | whether the node unfurls posted links |
+| `search_listed` / `_ack` | C→N / N⇒C | signed | whether members' cross-group Search lists this group |
| `root_update` / `_ack` | C→N / N⇒C | signed | a root's `writable` / `removable` flags |
| `root_eject` / `_ack`, `root_plug` / `_ack` | C→N / N⇒C | signed | take a removable root offline, put it back |
| `gek_rotate` / `_ack` | C→N / N→C | signed | node generates a new group key |
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 8638e60..3e9fe11 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -99,6 +99,11 @@ OP_ROOT_REMOVE = "root_remove"
OP_APP_DIRECTORIES = "app_directories"
OP_CHAT_DIRECTORY = "chat_directory"
OP_CHAT_LINK_PREVIEW = "chat_link_preview"
+# Whether this group's files appear in its members' cross-group Search. A
+# presentation choice and nothing more — every member still lists the group by
+# opening it — but it changes what every member's Search shows, so it is the
+# operator's, signed like the other per-group switches.
+OP_SEARCH_LISTED = "search_listed"
# Open a new chat epoch for a group, by hand. The removals that matter open one
# by themselves (member revoke/unpin, device revoke, gek_rotate); this is the
# operator saying "do it anyway", which is the same shape as `gek_rotate` and
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 1dfa519..f4cd497 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -223,6 +223,9 @@ class MNP:
CHAT_DIRECTORY_ACK = "chat_directory_ack"
CHAT_LINK_PREVIEW = "chat_link_preview"
CHAT_LINK_PREVIEW_ACK = "chat_link_preview_ack"
+ # Whether the group's files are listed in members' cross-group Search.
+ SEARCH_LISTED = "search_listed"
+ SEARCH_LISTED_ACK = "search_listed_ack"
# The keys a group's chat archive is encrypted under, on their way to a
# member. Sealed under a group-derived subkey, so the payload carries an
# authentication tag from a key the hub does not hold — and a member who has
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 0f28adf..988bcb1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -149,6 +149,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// destination rather than a set of folders it reads.
const [chatDirectory, setChatDirectory] = useState('');
const [chatLinkPreview, setChatLinkPreview] = useState(true);
+ // Whether members' cross-group Search lists this group. Not an app setting:
+ // it is about the group as a whole, and it hides nothing from this page.
+ const [searchListed, setSearchListed] = useState(true);
// MusicBrainz on/off (per-group) — docs/musicbay.md §3.2.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
const onPlayQueue = useCallback((tracks, startIndex) => {
@@ -401,6 +404,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
.map((k) => [k.slice(0, -'_directories'.length), ack[k] || []])));
setChatDirectory(ack.chat_directory || '');
setChatLinkPreview(ack.chat_link_preview !== false);
+ setSearchListed(ack.search_listed !== false);
setMusicbrainzConfig({
enabled: ack.musicbrainz_enabled !== false,
});
@@ -417,6 +421,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setAppDirectories((prev) => ({ ...prev, [app]: dirs }));
transport.onChatDirectory = (path) => setChatDirectory(path);
transport.onChatLinkPreview = (on) => setChatLinkPreview(on);
+ transport.onSearchListed = (listed) => setSearchListed(listed);
transport.onMusicbrainzEnabled = (enabled) =>
setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }));
transport.onRootsChanged = (msg) => {
@@ -865,6 +870,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
onScanSettings=${(s) => setScanSettings(s)}
+ searchListed=${searchListed}
entries=${entries} nodeDirs=${nodeDirs}
appSettings=${appSettings}
${/* The saving pane already knows what it asked for; this is so
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 0f8ccdd..4d67348 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -399,6 +399,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
mnpRoots,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
+ searchListed,
entries, nodeDirs,
appSettings,
onAppDirectories, onRefreshIndex,
@@ -652,6 +653,27 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
+ // Whether members' cross-group Search lists this group. The switch follows
+ // the node's answer: the ack is broadcast and replayed to the requester
+ // (transport.js BROADCAST_ACK_TYPES), which is what moves `searchListed`.
+ const [listedBusy, setListedBusy] = useState(false);
+ const [listedMsg, setListedMsg] = useState('');
+ const toggleSearchListed = useCallback(async (next) => {
+ const transport = transportRef && transportRef.current;
+ setListedMsg('');
+ setListedBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ await transport.setSearchListed(next, adminSignFn);
+ } catch (err) {
+ setListedMsg(err.message);
+ } finally {
+ setListedBusy(false);
+ }
+ }, [transportRef, adminSignFn]);
+
// ── Per-app settings ────────────────────────────────────────────────
//
// What every app's settings pane is given, and the one operation the page
@@ -936,6 +958,23 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+ ${/* Whether this group shows up in members' cross-group Search. A
+ listing preference, not a permission: the hint says so, because a
+ switch next to "members" and "devices" reads as access control. */
+ isNodeAdmin && connected && html`
+ <${CollapsibleSection} titleKey="settings_node.search_listed_title"
+ defaultOpen=${false}>
+ <div class="settings-row">
+ <${ToggleSwitch} checked=${searchListed !== false}
+ disabled=${listedBusy}
+ onChange=${toggleSearchListed}
+ label=${t('settings_node.search_listed_label')} />
+ </div>
+ <p class="settings-hint">${t('settings_node.search_listed_hint')}</p>
+ ${listedMsg && html`<p class="error-msg">${listedMsg}</p>`}
+ </${CollapsibleSection}>
+ `}
+
${/* How hard the node works watching its own disk — indexer.py
DirectoryIndexer. A performance knob, not a permission: it
changes nothing about who can see or do what. */
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 8607011..4ad8b4b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -710,6 +710,7 @@ export default {
'search.hint': 'Tippen Sie einen Begriff ein, um in allen Ihren Gruppen zu suchen.',
'search.indexing': 'Indexierung von {done} / {total} Gruppen …',
'search.unreachable': { one: '{n} Gruppe nicht erreichbar', other: '{n} Gruppen nicht erreichbar' },
+ 'search.unlisted': { one: '{n} Gruppe wird in der Suche nicht angezeigt – öffnen Sie sie zum Durchsuchen', other: '{n} Gruppen werden in der Suche nicht angezeigt – öffnen Sie sie zum Durchsuchen' },
'search.n_sources': { one: '{n} Quelle', other: '{n} Quellen' },
'search.view_files': 'Dateien',
'search.view_videos': 'Videos',
@@ -889,6 +890,9 @@ export default {
'settings_node.roots': 'Freigegebene Verzeichnisse',
'settings_node.scan_title': 'Scan',
'settings_node.scan_hint': 'Wie oft der Node seine Verzeichnisse erneut auf verpasste Änderungen prüft und wie lange er nach einer Änderung wartet, bevor eine Datei indiziert wird.',
+ 'settings_node.search_listed_title': 'Suche',
+ 'settings_node.search_listed_label': 'Diese Gruppe in der Suche anzeigen',
+ 'settings_node.search_listed_hint': 'Wenn deaktiviert, erscheinen die Dateien dieser Gruppe nicht in der gruppenübergreifenden Suche der Mitglieder. Mitglieder sehen weiterhin alles, wenn sie die Gruppe öffnen: Das nimmt sie nur aus der globalen Auflistung und verbirgt vor niemandem etwas.',
'settings_node.scan_reconcile_label': 'Prüfintervall (Minuten)',
'settings_node.scan_debounce_label': 'Wartezeit nach einer Änderung (Sekunden)',
'settings_node.scan_save': 'Speichern',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index fee6cc3..43a0db0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -630,6 +630,9 @@ export default {
'settings_node.detach_failed_continue': 'Could not detach the group from the node. Delete on hub anyway?',
'settings_node.scan_title': 'Scanning',
'settings_node.scan_hint': 'How often the node re-checks its directories for changes it may have missed, and how long it waits after a file changes before indexing it.',
+ 'settings_node.search_listed_title': 'Search',
+ 'settings_node.search_listed_label': 'List this group in Search',
+ 'settings_node.search_listed_hint': 'When off, this group\'s files do not appear in members\' cross-group Search. Members still see everything by opening the group: this keeps it out of the global listing and hides nothing from anyone.',
'settings_node.scan_reconcile_label': 'Re-check interval (minutes)',
'settings_node.scan_debounce_label': 'Wait after a change (seconds)',
'settings_node.scan_save': 'Save',
@@ -819,6 +822,7 @@ export default {
'search.hint': 'Type to search file names across all your groups.',
'search.indexing': 'Indexing {done} / {total} groups...',
'search.unreachable': { one: '{n} group unreachable', other: '{n} groups unreachable' },
+ 'search.unlisted': { one: '{n} group is not listed in Search — open it to browse', other: '{n} groups are not listed in Search — open them to browse' },
'search.n_sources': { one: '{n} source', other: '{n} sources' },
'search.view_files': 'Files',
'search.view_videos': 'Videos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index b958da3..0df5a38 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -705,6 +705,7 @@ export default {
'search.hint': 'Escriba un término para buscar en todos sus grupos.',
'search.indexing': 'Indexando {done} / {total} grupos…',
'search.unreachable': { one: '{n} grupo inalcanzable', other: '{n} grupos inalcanzables' },
+ 'search.unlisted': { one: '{n} grupo no aparece en la búsqueda: ábrelo para explorarlo', other: '{n} grupos no aparecen en la búsqueda: ábrelos para explorarlos' },
'search.n_sources': { one: '{n} fuente', other: '{n} fuentes' },
'search.view_files': 'Archivos',
'search.view_videos': 'Vídeos',
@@ -884,6 +885,9 @@ export default {
'settings_node.roots': 'Directorios compartidos',
'settings_node.scan_title': 'Escaneo',
'settings_node.scan_hint': 'Con qué frecuencia el node vuelve a comprobar sus directorios en busca de cambios que pudo haber pasado por alto, y cuánto tiempo espera tras un cambio antes de indexar un archivo.',
+ 'settings_node.search_listed_title': 'Búsqueda',
+ 'settings_node.search_listed_label': 'Mostrar este grupo en la búsqueda',
+ 'settings_node.search_listed_hint': 'Si está desactivado, los archivos de este grupo no aparecen en la búsqueda global de los miembros. Los miembros siguen viéndolo todo al abrir el grupo: solo lo saca del listado global y no oculta nada a nadie.',
'settings_node.scan_reconcile_label': 'Intervalo de comprobación (minutos)',
'settings_node.scan_debounce_label': 'Espera tras un cambio (segundos)',
'settings_node.scan_save': 'Guardar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c993d9c..e7dcacd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -708,6 +708,7 @@ export default {
'search.hint': 'Saisissez un terme pour rechercher dans tous vos groupes.',
'search.indexing': 'Indexation de {done} / {total} groupes…',
'search.unreachable': { one: '{n} groupe injoignable', other: '{n} groupes injoignables' },
+ 'search.unlisted': { one: '{n} groupe n\'est pas affiché dans la recherche — ouvrez-le pour le parcourir', other: '{n} groupes ne sont pas affichés dans la recherche — ouvrez-les pour les parcourir' },
'search.n_sources': { one: '{n} source', other: '{n} sources' },
'search.view_files': 'Fichiers',
'search.view_videos': 'Vidéos',
@@ -902,6 +903,9 @@ export default {
'settings_node.roots': 'Répertoires partagés',
'settings_node.scan_title': 'Analyse',
'settings_node.scan_hint': 'À quelle fréquence le node revérifie ses répertoires à la recherche de changements manqués, et combien de temps il attend après une modification avant d\'indexer un fichier.',
+ 'settings_node.search_listed_title': 'Recherche',
+ 'settings_node.search_listed_label': 'Afficher ce groupe dans la recherche',
+ 'settings_node.search_listed_hint': 'Désactivé, les fichiers de ce groupe n\'apparaissent pas dans la recherche globale des membres. Les membres voient toujours tout en ouvrant le groupe : cela le retire du listage global, sans rien cacher à personne.',
'settings_node.scan_reconcile_label': 'Intervalle de revérification (minutes)',
'settings_node.scan_debounce_label': 'Attente après un changement (secondes)',
'settings_node.scan_save': 'Enregistrer',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 3d4b502..26de960 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -707,6 +707,7 @@ export default {
'search.hint': 'Digita un termine per cercare in tutti i tuoi gruppi.',
'search.indexing': 'Indicizzazione di {done} / {total} gruppi…',
'search.unreachable': { one: '{n} gruppo non raggiungibile', other: '{n} gruppi non raggiungibili' },
+ 'search.unlisted': { one: '{n} gruppo non è mostrato nella ricerca: aprilo per sfogliarlo', other: '{n} gruppi non sono mostrati nella ricerca: aprili per sfogliarli' },
'search.n_sources': { one: '{n} fonte', other: '{n} fonti' },
'search.view_files': 'File',
'search.view_videos': 'Video',
@@ -898,6 +899,9 @@ export default {
'settings_node.roots': 'Directory condivise',
'settings_node.scan_title': 'Scansione',
'settings_node.scan_hint': 'Con quale frequenza il node ricontrolla le sue directory per cambiamenti che potrebbe aver perso, e quanto tempo attende dopo una modifica prima di indicizzare un file.',
+ 'settings_node.search_listed_title': 'Ricerca',
+ 'settings_node.search_listed_label': 'Mostra questo gruppo nella ricerca',
+ 'settings_node.search_listed_hint': 'Se disattivato, i file di questo gruppo non compaiono nella ricerca globale dei membri. I membri vedono comunque tutto aprendo il gruppo: lo esclude solo dall\'elenco globale e non nasconde nulla a nessuno.',
'settings_node.scan_reconcile_label': 'Intervallo di ricontrollo (minuti)',
'settings_node.scan_debounce_label': 'Attesa dopo una modifica (secondi)',
'settings_node.scan_save': 'Salva',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 3140527..4db42a1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -695,6 +695,7 @@ export default {
'search.hint': '入力してすべてのグループを検索します。',
'search.indexing': '{done} / {total} グループをインデックス中…',
'search.unreachable': { other: '{n} グループに接続できません' },
+ 'search.unlisted': { other: '{n} グループは検索に表示されません — 開いて閲覧してください' },
'search.n_sources': { other: '{n} 個のソース' },
'search.view_files': 'ファイル',
'search.view_videos': '動画',
@@ -882,6 +883,9 @@ export default {
'settings_node.roots': '共有ディレクトリ',
'settings_node.scan_title': 'スキャン',
'settings_node.scan_hint': 'node が見逃した変更がないかディレクトリを再確認する頻度と、ファイルの変更後にインデックスするまで待つ時間。',
+ 'settings_node.search_listed_title': '検索',
+ 'settings_node.search_listed_label': 'このグループを検索に表示する',
+ 'settings_node.search_listed_hint': 'オフにすると、このグループのファイルはメンバーのグループ横断検索に表示されません。メンバーはグループを開けば引き続きすべて見られます。全体の一覧から外すだけで、誰に対しても何も隠しません。',
'settings_node.scan_reconcile_label': '再確認の間隔(分)',
'settings_node.scan_debounce_label': '変更後の待機時間(秒)',
'settings_node.scan_save': '保存',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 1e55c8c..a345e94 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -709,6 +709,7 @@ export default {
'search.hint': 'Typ een zoekterm om in al uw groepen te zoeken.',
'search.indexing': '{done} / {total} groepen indexeren…',
'search.unreachable': { one: '{n} groep onbereikbaar', other: '{n} groepen onbereikbaar' },
+ 'search.unlisted': { one: '{n} groep wordt niet getoond in Zoeken — open haar om te bladeren', other: '{n} groepen worden niet getoond in Zoeken — open ze om te bladeren' },
'search.n_sources': { one: '{n} bron', other: '{n} bronnen' },
'search.view_files': 'Bestanden',
'search.view_videos': 'Video\'s',
@@ -900,6 +901,9 @@ export default {
'settings_node.roots': 'Gedeelde mappen',
'settings_node.scan_title': 'Scannen',
'settings_node.scan_hint': 'Hoe vaak de node zijn mappen opnieuw controleert op wijzigingen die zijn gemist, en hoe lang hij na een wijziging wacht voordat een bestand wordt geïndexeerd.',
+ 'settings_node.search_listed_title': 'Zoeken',
+ 'settings_node.search_listed_label': 'Deze groep tonen in Zoeken',
+ 'settings_node.search_listed_hint': 'Uitgeschakeld verschijnen de bestanden van deze groep niet in de groepsoverstijgende zoekfunctie van leden. Leden zien nog steeds alles door de groep te openen: dit haalt haar alleen uit de globale lijst en verbergt niets voor niemand.',
'settings_node.scan_reconcile_label': 'Controle-interval (minuten)',
'settings_node.scan_debounce_label': 'Wachttijd na een wijziging (seconden)',
'settings_node.scan_save': 'Opslaan',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 8486718..665fd0b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -727,6 +727,7 @@ export default {
'search.hint': 'Wpisz frazę, aby wyszukać we wszystkich grupach.',
'search.indexing': 'Indeksowanie {done} / {total} grup…',
'search.unreachable': { one: '{n} grupa nieosiągalna', few: '{n} grupy nieosiągalne', many: '{n} grup nieosiągalnych', other: '{n} grupy nieosiągalnej' },
+ 'search.unlisted': { one: '{n} grupa nie jest pokazywana w wyszukiwaniu — otwórz ją, aby przeglądać', few: '{n} grupy nie są pokazywane w wyszukiwaniu — otwórz je, aby przeglądać', many: '{n} grup nie jest pokazywanych w wyszukiwaniu — otwórz je, aby przeglądać', other: '{n} grupy nie jest pokazywane w wyszukiwaniu — otwórz je, aby przeglądać' },
'search.n_sources': { one: '{n} źródło', few: '{n} źródła', many: '{n} źródeł', other: '{n} źródła' },
'search.view_files': 'Pliki',
'search.view_videos': 'Filmy',
@@ -926,6 +927,9 @@ export default {
'settings_node.roots': 'Katalogi współdzielone',
'settings_node.scan_title': 'Skanowanie',
'settings_node.scan_hint': 'Jak często node ponownie sprawdza swoje katalogi w poszukiwaniu pominiętych zmian oraz jak długo czeka po zmianie pliku przed jego zindeksowaniem.',
+ 'settings_node.search_listed_title': 'Wyszukiwanie',
+ 'settings_node.search_listed_label': 'Pokazuj tę grupę w wyszukiwaniu',
+ 'settings_node.search_listed_hint': 'Po wyłączeniu pliki tej grupy nie pojawiają się w globalnym wyszukiwaniu członków. Członkowie nadal widzą wszystko po otwarciu grupy: to tylko usuwa ją z globalnej listy i niczego przed nikim nie ukrywa.',
'settings_node.scan_reconcile_label': 'Interwał sprawdzania (minuty)',
'settings_node.scan_debounce_label': 'Oczekiwanie po zmianie (sekundy)',
'settings_node.scan_save': 'Zapisz',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index e51df81..a85d96e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -706,6 +706,7 @@ export default {
'search.hint': 'Digite um termo para pesquisar em todos os seus grupos.',
'search.indexing': 'Indexando {done} / {total} grupos…',
'search.unreachable': { one: '{n} grupo inacessível', other: '{n} grupos inacessíveis' },
+ 'search.unlisted': { one: '{n} grupo não aparece na busca — abra-o para navegar', other: '{n} grupos não aparecem na busca — abra-os para navegar' },
'search.n_sources': { one: '{n} fonte', other: '{n} fontes' },
'search.view_files': 'Arquivos',
'search.view_videos': 'Vídeos',
@@ -885,6 +886,9 @@ export default {
'settings_node.roots': 'Diretórios compartilhados',
'settings_node.scan_title': 'Varredura',
'settings_node.scan_hint': 'Com que frequência o node reverifica seus diretórios em busca de mudanças que possa ter perdido, e quanto tempo espera após uma mudança antes de indexar um arquivo.',
+ 'settings_node.search_listed_title': 'Busca',
+ 'settings_node.search_listed_label': 'Mostrar este grupo na busca',
+ 'settings_node.search_listed_hint': 'Desativado, os arquivos deste grupo não aparecem na busca global dos membros. Os membros continuam vendo tudo ao abrir o grupo: isso só o retira da listagem global e não esconde nada de ninguém.',
'settings_node.scan_reconcile_label': 'Intervalo de reverificação (minutos)',
'settings_node.scan_debounce_label': 'Espera após uma mudança (segundos)',
'settings_node.scan_save': 'Salvar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index cc22a70..3e7c1c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -682,6 +682,7 @@ export default {
'search.hint': '输入关键词搜索所有群组。',
'search.indexing': '正在索引 {done} / {total} 个群组…',
'search.unreachable': { other: '{n} 个群组无法连接' },
+ 'search.unlisted': { other: '{n} 个群组未在搜索中显示——打开群组即可浏览' },
'search.n_sources': { other: '{n} 个来源' },
'search.view_files': '文件',
'search.view_videos': '视频',
@@ -869,6 +870,9 @@ export default {
'settings_node.roots': '共享目录',
'settings_node.scan_title': '扫描',
'settings_node.scan_hint': 'node 多久重新检查一次目录以发现可能错过的变化,以及文件变化后等待多久才建立索引。',
+ 'settings_node.search_listed_title': '搜索',
+ 'settings_node.search_listed_label': '在搜索中显示此群组',
+ 'settings_node.search_listed_hint': '关闭后,此群组的文件不会出现在成员的跨群组搜索中。成员打开群组后仍可看到全部内容:这只是将其移出全局列表,不对任何人隐藏任何内容。',
'settings_node.scan_reconcile_label': '重新检查间隔(分钟)',
'settings_node.scan_debounce_label': '变化后的等待时间(秒)',
'settings_node.scan_save': '保存',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index ee99ccc..a77e8c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -32,6 +32,51 @@ const SEARCH_VIDEO_ROOT = '__search__';
const SEARCH_AUDIO_ROOT = '__search__';
const SEARCH_PHOTO_ROOTS = ['__search_photos__'];
+// -- Connecting to a group ----------------------------------------------------
+
+/**
+ * A live connection to one of the nodes serving `groupId`, and its ack — or
+ * null when the hub lists no node at all.
+ *
+ * Every node the hub lists, in turn, exactly as group-page.js does since
+ * 2026-09-11: the list is in hub registration order, and its head is not
+ * necessarily a node that answers. Search took `nodes[0]` and stopped, so a
+ * group with a second, working node counted as unreachable here while it
+ * opened fine from the sidebar. A refusal naming a state of this browser (a
+ * code, a passphrase, a device) is the same from every node and stops the
+ * walk; `not_hosted`, a timeout or a failed connection moves on.
+ */
+async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId) {
+ const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
+ if (!nodesData.nodes || !nodesData.nodes.length) return null;
+
+ const live = (await ensureFreshToken()) || token;
+ let lastErr = null;
+ for (const n of nodesData.nodes) {
+ const transport = new window.MeshBayTransport(hubBase, live);
+ transport.onNeedToken = async () => (await ensureFreshToken()) || token;
+ let timer;
+ try {
+ const ack = await Promise.race([
+ transport.connect(
+ n.node_id, live, groupId, null, null, bundleKey,
+ username, userId, null),
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT);
+ }),
+ ]);
+ clearTimeout(timer);
+ return { transport, ack };
+ } catch (e) {
+ clearTimeout(timer);
+ lastErr = e;
+ try { transport.close(); } catch {}
+ if (e.reason && e.reason !== 'not_hosted') throw e;
+ }
+ }
+ throw (lastErr || new Error('no node served this group'));
+}
+
// -- Connection pool ----------------------------------------------------------
class ConnectionPool {
@@ -66,29 +111,10 @@ class ConnectionPool {
}
async _doConnect(groupId, token, bundleKey, username, userId) {
- const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
- if (!nodesData.nodes || !nodesData.nodes.length) throw new Error('offline');
-
- const live = (await ensureFreshToken()) || token;
- const transport = new window.MeshBayTransport(this._hubBase, live);
- transport.onNeedToken = async () => (await ensureFreshToken()) || token;
-
- let timer;
- try {
- await Promise.race([
- transport.connect(
- nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey,
- username, userId, null),
- new Promise((_, reject) => {
- timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT);
- }),
- ]);
- clearTimeout(timer);
- } catch (e) {
- clearTimeout(timer);
- try { transport.close(); } catch {}
- throw e;
- }
+ const found = await connectToGroup(
+ this._hubBase, groupId, token, bundleKey, username, userId);
+ if (!found) throw new Error('offline');
+ const { transport } = found;
let gek = null;
if (transport.gekRaw && window.MeshBayCrypto) {
@@ -128,24 +154,17 @@ class ConnectionPool {
// -- Index fetching -----------------------------------------------------------
async function fetchGroupIndex(groupId, token, bundleKey, username, userId) {
- const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
- if (!nodesData.nodes || !nodesData.nodes.length) return null;
-
- const live = (await ensureFreshToken()) || token;
- const transport = new window.MeshBayTransport(HUB, live);
- transport.onNeedToken = async () => (await ensureFreshToken()) || token;
+ const found = await connectToGroup(HUB, groupId, token, bundleKey, username, userId);
+ if (!found) return null;
+ const { transport, ack } = found;
try {
- let timer;
- const ack = await Promise.race([
- transport.connect(
- nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey,
- username, userId, null),
- new Promise((_, reject) => {
- timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT);
- }),
- ]);
- clearTimeout(timer);
+ // The operator asked for this group to stay out of the global listing.
+ // Decided here, before the index is asked for, so nothing of it is held,
+ // cached or merged by this page. A listing preference and not a boundary:
+ // the node cannot tell this request from the group page's, and opening the
+ // group lists everything.
+ if (ack.search_listed === false) return { unlisted: true };
const indexMsg = await transport.fetchIndex();
// Plural, with the old scalars as the fallback for a node still speaking
@@ -174,6 +193,7 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa
const total = groups.length;
let done = 0;
const unreachable = [];
+ const unlisted = [];
const results = new Map();
for (let i = 0; i < groups.length; i += BATCH_SIZE) {
@@ -181,7 +201,9 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa
await Promise.all(batch.map(async (g) => {
try {
const result = await fetchGroupIndex(g.id, token, bundleKey, username, userId);
- if (result) {
+ if (result && result.unlisted) {
+ unlisted.push(g.name || g.id);
+ } else if (result) {
results.set(g.id, {
...result,
groupName: g.name,
@@ -195,11 +217,11 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa
unreachable.push(g.name || g.id);
}
done++;
- onProgress({ done, total, unreachable: [...unreachable] });
+ onProgress({ done, total, unreachable: [...unreachable], unlisted: [...unlisted] });
}));
onBatch(new Map(results));
}
- return { results, unreachable };
+ return { results, unreachable, unlisted };
}
// -- SearchPage ---------------------------------------------------------------
@@ -281,7 +303,8 @@ function photoUnits(entries) {
function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) {
const [indexedGroups, setIndexedGroups] = useState(new Map());
- const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] });
+ const [progress, setProgress] = useState(
+ { done: 0, total: 0, unreachable: [], unlisted: [] });
const [fetching, setFetching] = useState(false);
// Bumped by the Files breadcrumb refresh button — re-runs the all-groups
// index fetch below, the only "cache" this page has.
@@ -333,7 +356,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
(async () => {
setFetching(true);
- setProgress({ done: 0, total: groups.length, unreachable: [] });
+ setProgress({ done: 0, total: groups.length, unreachable: [], unlisted: [] });
await fetchAllIndexes(
groups, token, username, userId,
@@ -718,6 +741,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
</p>
`}
+ ${!fetching && progress.unlisted.length > 0 && html`
+ <p class="search-unreachable">
+ ${t('search.unlisted', { n: progress.unlisted.length })}
+ </p>
+ `}
+
${viewMode === 'files' && hasResults && html`
<${FilesPanel}
groupId="search"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 9de5407..1f05b8d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -96,7 +96,7 @@ const BROADCAST_ACK_TYPES = new Set([
'root_update_ack', 'root_eject_ack', 'root_plug_ack',
'root_add_ack', 'root_remove_ack',
'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
- 'chat_epoch_ack',
+ 'chat_epoch_ack', 'search_listed_ack',
]);
/** Hand a broadcast ack to the callback that would have had it from a peer. */
@@ -107,6 +107,8 @@ function _replayBroadcast(transport, msg) {
transport._onChatDirectory(msg.path || '');
} else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) {
transport._onChatLinkPreview(Boolean(msg.enabled));
+ } else if (msg.type === 'search_listed_ack' && transport._onSearchListed) {
+ transport._onSearchListed(msg.listed !== false);
} else if (msg.type === 'chat_epoch_ack') {
transport._applyChatEpoch(msg);
} else if (transport._onRootsChanged) {
@@ -120,7 +122,7 @@ const ADMIN_OP_TYPES = new Set([
'apps_enabled', 'set_scan_settings', 'member_revoke',
'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch',
- 'member_unpin', 'gek_rotate', 'group_attach',
+ 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach',
'group_detach', 'invite_create',
]);
@@ -533,6 +535,7 @@ class MeshBayTransport {
set onAppDirectories(fn) { this._onAppDirectories = fn; }
set onChatDirectory(fn) { this._onChatDirectory = fn; }
set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; }
+ set onSearchListed(fn) { this._onSearchListed = fn; }
set onChatEpoch(fn) { this._onChatEpoch = fn; }
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
@@ -1526,6 +1529,22 @@ class MeshBayTransport {
}
/**
+ * Whether this group's files appear in members' cross-group Search.
+ * Presentation only — opening the group lists everything regardless.
+ */
+ async setSearchListed(listed, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'search_listed', v: '3.0', listed: Boolean(listed),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'search_listed', listed ? 'on' : 'off', signFn);
+ }
+ return msg;
+ }
+
+ /**
* Open a new chat epoch by hand. Operator only, and signed.
*
* Not a switch — there is nothing to turn on. The removals that matter open
@@ -3097,6 +3116,9 @@ class MeshBayTransport {
if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) {
this._onChatLinkPreview(Boolean(msg.enabled));
}
+ if (msg.type === 'search_listed_ack' && this._onSearchListed) {
+ this._onSearchListed(msg.listed !== false);
+ }
if (msg.type === 'chat_epoch_ack') {
this._applyChatEpoch(msg);
return;
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
index 940d975..027fee6 100644
--- a/packages/meshbay-hub/tests/test_app_settings_plugin.py
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -224,8 +224,9 @@ def test_the_page_performs_exactly_one_app_specific_operation():
"GroupSettingsPanel")
calls = set(re.findall(r"transport\.(set\w+)\(", panel))
# The page's own settings, which belong to no app: which apps are enabled
- # at all, and how hard the node works watching its disk.
- page_level = {"setAppsEnabled", "setScanSettings"}
+ # at all, how hard the node works watching its disk, and whether members'
+ # cross-group Search lists the group.
+ page_level = {"setAppsEnabled", "setScanSettings", "setSearchListed"}
# One generic operation, keyed by the app's own name: adding an app adds
# no message type and no call site here.
generic = {"setAppDirectories"}
diff --git a/packages/meshbay-hub/tests/test_search_unlisted.py b/packages/meshbay-hub/tests/test_search_unlisted.py
new file mode 100644
index 0000000..4ffec32
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_search_unlisted.py
@@ -0,0 +1,96 @@
+"""
+A group the operator left out of Search is not listed there, in any view.
+
+`search_listed` is a presentation preference, not an access control (design
+§9.11): the node serves the same index to Search and to the group page. What
+this file holds is the client half — that Search stops at the handshake ack for
+such a group, so nothing of its index is fetched, cached, merged or shown — and
+that the switch in Settings reaches the page that renders it.
+
+Source-reading, like the other Search tests: weak evidence, and the only kind
+available for the SPA. Each check is a one-line edit away from failing, which is
+what a source-reading test catches well.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SEARCH_PAGE = STATIC / "search-page.js"
+TRANSPORT = STATIC / "transport.js"
+GROUP_PAGE = STATIC / "group-page.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+
+pytestmark = pytest.mark.skipif(
+ not SEARCH_PAGE.exists(), reason="the SPA sources are not available")
+
+
+def _function(src: str, name: str) -> str:
+ m = re.search(r"^async function " + re.escape(name) + r"\(.*?^\}", src, re.M | re.S)
+ assert m, f"{name} is no longer where this test reads it"
+ return m.group(0)
+
+
+def test_search_stops_before_asking_for_the_index():
+ body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchGroupIndex")
+ stop = body.find("ack.search_listed === false")
+ fetch = body.find("transport.fetchIndex()")
+ assert stop != -1, "fetchGroupIndex no longer reads search_listed off the ack"
+ assert fetch != -1
+ assert stop < fetch, (
+ "the unlisted check comes after the index is fetched — the index of a "
+ "group the operator left out of Search is then held by the page")
+
+
+def test_an_unlisted_group_is_neither_indexed_nor_cached_nor_unreachable():
+ body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchAllIndexes")
+ branch = body[body.index("result.unlisted"):]
+ branch = branch[:branch.index("} else if (result)")]
+ assert "unlisted.push" in branch
+ for forbidden in ("results.set", "cacheGroupIndex", "unreachable.push"):
+ assert forbidden not in branch, (
+ f"an unlisted group reaches `{forbidden}` — it would be shown, "
+ "cached, or reported as down")
+
+
+def test_every_search_view_is_built_from_the_indexed_groups_only():
+ """
+ The four views read `indexedGroups`, which only `fetchAllIndexes`'s results
+ fill. If a view ever read the group list directly, an unlisted group would
+ come back through it.
+ """
+ src = SEARCH_PAGE.read_text(encoding="utf-8")
+ for memo in ("fileEntries", "videoEntries", "musicEntries", "photoEntries"):
+ m = re.search(r"^ const " + memo + r" = useMemo\(\(\) => \{.*?^ \}, \[",
+ src, re.M | re.S)
+ assert m, f"{memo} is no longer a useMemo"
+ assert "for (const [groupId, data] of indexedGroups)" in m.group(0)
+ assert "groups" not in m.group(0).replace("indexedGroups", ""), (
+ f"{memo} reads the raw group list")
+
+
+def test_the_operator_hears_back_from_their_own_change():
+ """Same failure as Chat's directory: an admin ack swallowed by its request."""
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ block = transport[transport.index("const BROADCAST_ACK_TYPES"):]
+ assert "'search_listed_ack'" in block[:block.index("]);")]
+ replay = transport[transport.index("function _replayBroadcast"):]
+ replay = replay[:replay.index("\n}") + 2]
+ assert "_onSearchListed" in replay
+
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ assert "transport.onSearchListed = " in page
+ assert "setSearchListed(ack.search_listed !== false)" in page, (
+ "absent must read as listed, or every group on an older node vanishes "
+ "from Search")
+
+
+def test_the_switch_is_offered_to_the_operator_only():
+ settings = GROUP_SETTINGS.read_text(encoding="utf-8")
+ at = settings.index("settings_node.search_listed_title")
+ guard = settings.rfind("isNodeAdmin && connected", 0, at)
+ assert guard != -1 and at - guard < 600, (
+ "the Search listing switch is rendered outside the operator's section")
+ assert "transport.setSearchListed(next, adminSignFn)" in settings
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index c506ff1..ab942e4 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -506,3 +506,31 @@ def test_the_refusal_the_loop_keys_on_has_a_message(transport):
refusals = transport[transport.index("const HANDSHAKE_REFUSALS"):]
refusals = refusals[:refusals.index("};")]
assert "not_hosted:" in refusals
+
+
+# Search had the same `nodes[0]` twice — once to read a group's index, once for
+# the pooled connection its thumbnails and playback use — and was not part of
+# the fix above, so a group with a second, working node counted as unreachable
+# there while it opened fine from the sidebar.
+
+SEARCH_PAGE = STATIC / "search-page.js"
+
+
+def test_search_tries_every_node_the_hub_offers():
+ code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8"))
+ assert "nodes[0]" not in code, "Search takes the head of the node list again"
+ walk = code[code.index("for (const n of nodesData.nodes)"):]
+ walk = walk[:walk.index("throw (lastErr")]
+ assert "not_hosted" in walk and "throw e" in walk, (
+ "the walk must stop for a refusal about this browser and move on "
+ "for one about this node")
+
+
+def test_search_connects_in_one_place():
+ """Two call sites with their own connect is how one of them kept `nodes[0]`."""
+ code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8"))
+ assert code.count("transport.connect(") == 1
+ pool = code[code.index("async _doConnect("):code.index("_evict() {")]
+ index = code[code.index("async function fetchGroupIndex("):
+ code.index("async function fetchAllIndexes(")]
+ assert "connectToGroup(" in pool and "connectToGroup(" in index
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