summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-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
25 files changed, 603 insertions, 48 deletions
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