diff options
Diffstat (limited to 'packages/meshbay-node')
12 files changed, 857 insertions, 173 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 4fc07ad..7637b41 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -70,26 +70,36 @@ if WEBRTC_AVAILABLE: log = logging.getLogger(__name__) -def _under_video_root(path: str, video_root: str) -> bool: - """Mirrors video-app.js's underVideoRoot: same folder, or a descendant.""" - path = path or "" - return path == video_root or path.startswith(video_root + "/") - +def _under_any_directory(path: str, directories: list[str]) -> bool: + """ + Whether an entry's folder is one of an app's directories, or inside one. -def _under_audio_root(path: str, audio_root: str) -> bool: - """Mirrors music-app.js's underAudioRoot — same shape as _under_video_root.""" + Mirrors `underAnyDirectory` in the SPA's app modules. One helper for every + app since they all take a list: Videos and Music used to take a single + folder and had a function each saying the same thing, which is how the two + came to differ in what they did with a trailing slash. + """ path = path or "" - return path == audio_root or path.startswith(audio_root + "/") + return any(path == d or path.startswith(d + "/") for d in directories) -def _under_any_photo_root(path: str, photo_roots: list[str]) -> bool: +def _owning_directory(path: str, directories: list[str]) -> str | None: """ - Mirrors photos-app.js's underAnyPhotoRoot. Unlike video/audio's single - root, photo_roots is a list (docs/photos.md §2.1) — a match against any - one of them is enough. + Which of an app's directories an entry belongs to — the deepest match. + + Deepest, because directories may nest: with both `Media` and + `Media/Albums` configured, a file under the second belongs to the second. + Taking the first match instead would measure it against a boundary one + level too shallow, which for Music is the difference between reading a + folder as an artist and reading it as a release. """ path = path or "" - return any(path == r or path.startswith(r + "/") for r in photo_roots) + best: str | None = None + for d in directories: + if path == d or path.startswith(d + "/"): + if best is None or len(d) > len(best): + best = d + return best # ── Argon2id calibration ────────────────────────────────────────────────────── @@ -396,19 +406,15 @@ class NodeDaemon: # which the RootSet above already carries.) "enabled_apps": await self._roster.enabled_apps( group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS), - # Which folder is the Videos app's entry point for this - # group — "" means the whole group index. - "video_root": await self._roster.video_root( - group_cfg.id) if self._roster else "", - # Same shape, Music app's own entry point. - "audio_root": await self._roster.audio_root( - group_cfg.id) if self._roster else "", - # Photos app's entry points — a *list*, unlike video_root/ - # audio_root above (docs/photos.md §2.1: a photo library - # is routinely scattered across several folders). Empty - # list means nothing configured yet. - "photo_roots": await self._roster.photo_roots( - group_cfg.id) if self._roster else [], + # Which folder(s) inside the shared roots each app works + # over. One shape for every app (roster.py's + # app_directories) — an empty list means nothing has been + # chosen, which every app reads as "show nothing yet", + # never "the whole group index". + **(await self._app_directories_ctx(group_cfg.id)), + # 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 TMDB lookups run for this group at all — # per-group (2026-08-24, used to be node-wide), same # "read once, kept current in place by the signed op" @@ -631,9 +637,14 @@ class NodeDaemon: self._state["quic_server"] = self._quic_server self._state["hub"] = hub self._state["reload_fn"] = self._reload_config - self._state["enrich_video_root_fn"] = self._enrich_video_root_now - self._state["enrich_audio_root_fn"] = self._enrich_audio_root_now - self._state["enrich_photo_roots_fn"] = self._enrich_photo_roots_now + # Keyed by app, so `ops.set_app_directories` finds the right + # sweep without knowing which apps exist — an app with nothing to + # enrich simply has no entry. + self._state["enrich_app_dirs_fns"] = { + "video": self._enrich_video_root_now, + "music": self._enrich_audio_root_now, + "photo": self._enrich_photo_roots_now, + } # Rotating a key has to reach every transport holding a copy of it, # and clearing the denylist has to reach the one the handshake # consults — so both are published rather than reachable only @@ -854,15 +865,10 @@ class NodeDaemon: "enabled_apps": ( await self._roster.enabled_apps(group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS)), - "video_root": ( - await self._roster.video_root(group_cfg.id) - if self._roster else ""), - "audio_root": ( - await self._roster.audio_root(group_cfg.id) - if self._roster else ""), - "photo_roots": ( - await self._roster.photo_roots(group_cfg.id) - if self._roster else []), + **(await self._app_directories_ctx(group_cfg.id)), + "chat_link_preview": ( + await self._roster.chat_link_preview(group_cfg.id) + if self._roster else True), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), @@ -1076,6 +1082,36 @@ class NodeDaemon: spec["ejected"] = True return RootSet.build(specs) + # Every app that keeps directories. Not derived from `enabled_apps`: the + # context is read once at load and an app enabled later must not find its + # own setting missing. Adding an app adds a name here and nowhere else on + # this side. + APP_DIR_KEYS = ("video", "music", "photo", "chat") + + async def _app_directories_ctx(self, group_id: str) -> dict: + """ + Each app's configured directories, plus the legacy scalar names the + rest of the tree still reads. + + The scalars are derived here rather than stored, so the two can never + disagree: `video_root` is the first of `video_directories` and exists + for MNP 1.0 clients and for the handful of call sites that predate the + list. A group with several video directories reports the first as its + `video_root` — which is what an old client can represent, and all it + could ever have shown. + """ + dirs = {} + for app in self.APP_DIR_KEYS: + dirs[f"{app}_directories"] = ( + await self._roster.app_directories(group_id, app) + if self._roster else []) + aliases = {} + for app in self.APP_DIR_KEYS: + alias = Roster.ctx_alias(app, dirs[f"{app}_directories"]) + if alias: + aliases[alias[0]] = alias[1] + return {**dirs, **aliases} + def _eject_persister(self, group_id: str): """`on_root_ejected` bound to one group, for that group's indexer.""" async def persist(root_name: str, ejected: bool) -> None: @@ -1249,13 +1285,13 @@ class NodeDaemon: """ if not self._enricher or not self._roster: return - video_root = await self._roster.video_root(indexer.group_id) - if not video_root: + video_dirs = await self._roster.app_directories(indexer.group_id, "video") + if not video_dirs: return for entry in entries: if entry.type != "video" or (indexer.group_id, entry.id) in self._enriched_attempted: continue - if not _under_video_root(entry.path, video_root): + if not _under_any_directory(entry.path, video_dirs): continue file_path = entry_abs_path(indexer.roots, entry) if not file_path or not file_path.exists(): @@ -1330,14 +1366,18 @@ class NodeDaemon: """ if not self._audio_enricher or not self._roster: return - audio_root = await self._roster.audio_root(indexer.group_id) - if not audio_root: + audio_dirs = await self._roster.app_directories(indexer.group_id, "music") + if not audio_dirs: return - root_boundary = indexer.roots.resolve(audio_root, require_available=False) + # Resolved once per directory, not once per file: a library is + # thousands of entries and this is a filesystem call each time. + boundaries = {d: indexer.roots.resolve(d, require_available=False) + for d in audio_dirs} for entry in entries: if entry.type != "audio" or (indexer.group_id, entry.id) in self._enriched_attempted: continue - if not _under_audio_root(entry.path, audio_root): + owner = _owning_directory(entry.path, audio_dirs) + if owner is None: continue file_path = entry_abs_path(indexer.roots, entry) if not file_path or not file_path.exists(): @@ -1347,13 +1387,16 @@ class NodeDaemon: async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: await self._on_enriched(_indexer, file_id, fields) - # `root_boundary` — audio_root itself, not the shared root it - # lives under — so the ancestor walk + # The boundary is *the configured directory this file is under*, + # not the shared root it lives in — so the ancestor walk # (enrich_audio._artist_album_from_ancestors) treats a flat - # top-level folder right under the *configured* Music root as - # ambiguous (artist-or-release, §2.1), not one level too shallow - # if audio_root is itself a subfolder of a larger shared root. - self._audio_enricher.spawn(entry, file_path, on_done, root_boundary) + # top-level folder right under the configured Music directory as + # ambiguous (artist-or-release, musicbay.md §2.1), rather than one + # level too shallow when that directory is itself a subfolder. + # With several configured, each file is measured against its own: + # a single shared boundary would be wrong for all but one of them. + self._audio_enricher.spawn(entry, file_path, on_done, + boundaries.get(owner)) async def _enrich_audio_root_now(self, group_id: str) -> None: """ @@ -1403,13 +1446,13 @@ class NodeDaemon: """ if not self._photo_enricher or not self._roster: return - photo_roots = await self._roster.photo_roots(indexer.group_id) - if not photo_roots: + photo_dirs = await self._roster.app_directories(indexer.group_id, "photo") + if not photo_dirs: return for entry in entries: if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted: continue - if not _under_any_photo_root(entry.path, photo_roots): + if not _under_any_directory(entry.path, photo_dirs): continue file_path = entry_abs_path(indexer.roots, entry) if not file_path or not file_path.exists(): diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 13a7250..f7f6190 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -1205,76 +1205,171 @@ async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) -> return {"enabled": enabled, "group_id": group_id} -async def set_video_root(state: dict, group_id: str, path: str) -> dict: +# ── App directories ────────────────────────────────────────────────────────── + +def _validate_app_dirs(state: dict, group_id: str, paths: list[str], *, + require_writable: bool) -> list[str]: """ - Which folder (possibly a subfolder of a shared root) is the Videos app's - entry point for this group. Same shape as set_enabled_apps: lives on the - node (roster.db), takes effect without a restart, signed by the operator. - `path=""` clears it — the Videos tab then asks for one to be chosen - before anything (including TMDB enrichment, docs/mediacenter.md §5.2) - runs, rather than defaulting to the whole shared index. + Every path an app is pointed at must live inside one of the group's roots. + + The per-app setters this replaces validated nothing: a typo, or a path left + behind by a root that was removed, was stored and then quietly matched no + entry — an app showing an empty tab with no way to tell "misconfigured" + from "no files yet". Refusing at the point of setting is the only moment + the operator is present to be told. - A non-empty path fires (never awaits) a sweep of whatever that folder - already contains: the ordinary per-change enrichment path only ever - looks at files new since the last broadcast, so anything already sitting - in a folder before it became the video_root would otherwise never be - picked up. + Not `RootSet.resolve()`, deliberately: that also refuses a directory whose + root is currently *unavailable*, and an operator must be able to configure + a library on a drive they have unplugged. What is checked here is the + shape — inside a named root, no traversal — which does not change with + what happens to be mounted. """ - roster = _roster(state) - ctx = _group_ctx(state, group_id) - await roster.set_video_root(group_id, path, set_by=state.get("node_user_id", "")) - ctx["video_root"] = path - log.info("Videos root for group %s: %r", group_id[:8], path) - if path: - enrich_fn = state.get("enrich_video_root_fn") - if enrich_fn: - asyncio.ensure_future(enrich_fn(group_id)) - return {"path": path, "group_id": group_id} + roots: RootSet | None = _group_ctx(state, group_id).get("roots") + if roots is None: + raise OpError("Group has no roots", status=503) + + clean: list[str] = [] + for raw in paths: + path = str(raw or "").strip().strip("/") + if not path: + continue + if ".." in path.split("/"): + raise OpError(f"{path!r} is not a directory inside this group", + status=400) + found = roots.split(path) + if found is None: + raise OpError( + f"{path!r} is not inside any of this group's shared " + f"directories", status=400, + extra={"available": roots.names}) + root, _tail = found + if require_writable and not root.writable: + raise OpError( + f"{root.name!r} is read-only, and this setting needs a " + f"directory that accepts uploads", status=400) + clean.append(path) + return sorted(set(clean)) -async def set_audio_root(state: dict, group_id: str, path: str) -> dict: +async def set_app_directories(state: dict, group_id: str, app_key: str, + paths: list[str], *, + require_writable: bool = False) -> dict: """ - Same shape as set_video_root above — the Music app's own entry point, - added later (docs/musicbay.md's original "no root, works over the - whole shared tree" simplification didn't hold up against a real messy - library). `path=""` clears it — the Music tab then asks for one to be - chosen before anything (including tag/cover enrichment) runs, rather - than defaulting to the whole shared index. + Which folder(s) inside the group's shared roots an application works over. + + One function for every app, keyed by the app's own name: adding an + application is a registry entry and a settings component, not another + near-identical op here. It replaces `set_video_root`, `set_audio_root` and + `set_photo_roots`, which differed only in the key they wrote and whether + they took a string or a list. + + Empty means nothing configured, which every app reads as "show nothing + until an operator has chosen" — never "the whole group index". Pointing an + app at the whole library is a decision, not a default nobody made. + + A change always fires (never awaits) a sweep of what the new directories + already contain: the ordinary per-change enrichment path only looks at + entries new since the last broadcast, so files already sitting in a folder + when it was chosen would otherwise never be picked up. """ roster = _roster(state) ctx = _group_ctx(state, group_id) - await roster.set_audio_root(group_id, path, set_by=state.get("node_user_id", "")) - ctx["audio_root"] = path - log.info("Music root for group %s: %r", group_id[:8], path) - if path: - enrich_fn = state.get("enrich_audio_root_fn") - if enrich_fn: - asyncio.ensure_future(enrich_fn(group_id)) - return {"path": path, "group_id": group_id} + clean = _validate_app_dirs(state, group_id, paths, + require_writable=require_writable) + await roster.set_app_directories(group_id, app_key, clean, + set_by=state.get("node_user_id", "")) + ctx[f"{app_key}_directories"] = clean + # The scalar the handshake ack still publishes for MNP 1.0 clients is + # derived, and has to be re-derived here: leaving it behind would make the + # ack disagree with the list within a single run, and only until a restart + # — the shape of bug that reads as "it works after a restart". + from meshbay_node.roster import Roster + alias = Roster.ctx_alias(app_key, clean) + if alias: + ctx[alias[0]] = alias[1] + log.info("%s directories for group %s: %s", app_key, group_id[:8], + ", ".join(clean) or "(none)") + + enrich = (state.get("enrich_app_dirs_fns") or {}).get(app_key) + if enrich: + asyncio.ensure_future(enrich(group_id)) + return {"app": app_key, "directories": clean, "group_id": group_id} + + +async def set_app_directory(state: dict, group_id: str, app_key: str, + path: str, *, + require_writable: bool = False) -> dict: + """ + The single-directory form, for an app that only ever wants one. + + Stored as a one-element list like every other app, because two storage + shapes for one idea is what made `video_root` (scalar) and `photo_roots` + (list) need separate ops, separate MNP messages and separate widgets to + say the same thing. `path=""` clears it. + """ + result = await set_app_directories( + state, group_id, app_key, [path] if path else [], + require_writable=require_writable) + dirs = result["directories"] + return {**result, "path": dirs[0] if dirs else ""} + + +# The per-app wrappers MNP still names. They exist so an MNP 1.0 client's +# `video_root` / `audio_root` / `photo_roots` messages keep working; nothing +# new should be added here — a new app calls the generic pair above. + +async def set_video_root(state: dict, group_id: str, path: str) -> dict: + result = await set_app_directory(state, group_id, "video", path) + return {"path": result["path"], "group_id": group_id} + + +async def set_audio_root(state: dict, group_id: str, path: str) -> dict: + # "music", not "audio": the app's registry key is what identifies it + # everywhere, and `audio_root` is only the name the setting used to have. + result = await set_app_directory(state, group_id, "music", path) + return {"path": result["path"], "group_id": group_id} async def set_photo_roots(state: dict, group_id: str, roots: list[str]) -> dict: + result = await set_app_directories(state, group_id, "photo", roots) + return {"roots": result["directories"], "group_id": group_id} + + +# ── Chat ───────────────────────────────────────────────────────────────────── + +async def set_chat_directory(state: dict, group_id: str, path: str) -> dict: """ - Which folder(s) are the Photos app's entry points for this group. Unlike - `set_video_root`/`set_audio_root`, the whole *set* is replaced in one - call (docs/photos.md §2.1) — signed once, same shape as - `set_enabled_apps`, rather than one op per root added/removed. + Where chat attachments are written. - Always fires a sweep, even to an empty list: a root just added needs its - existing contents enriched (nothing else re-visits already-indexed - entries), and a root just removed leaves its cache entries harmlessly - unused rather than needing any cleanup — re-sweeping the new set costs - nothing when it's empty. + `require_writable`, unlike every other app directory: this one is a + *destination*, not a view. Pointing it at a read-only root would produce an + attachment button that fails at the moment somebody uses it, which is the + failure mode the RO/RW model exists to move earlier. + """ + return await set_app_directory(state, group_id, "chat", path, + require_writable=True) + + +async def set_chat_link_preview(state: dict, group_id: str, + enabled: bool) -> dict: + """ + Whether the node fetches a page's title and image when a member posts a + link. + + Outbound third-party traffic on the operator's connection, caused by a + message they did not write and pointing at a URL they did not choose — so + it is theirs to switch off, on the same reasoning as the per-group TMDB + switch. Absent means on, because that is what the node did before this + existed. """ roster = _roster(state) ctx = _group_ctx(state, group_id) - await roster.set_photo_roots(group_id, roots, set_by=state.get("node_user_id", "")) - ctx["photo_roots"] = roots - log.info("Photo roots for group %s: %s", group_id[:8], ", ".join(sorted(roots)) or "(none)") - enrich_fn = state.get("enrich_photo_roots_fn") - if enrich_fn: - asyncio.ensure_future(enrich_fn(group_id)) - return {"roots": roots, "group_id": group_id} + await roster.set_chat_link_preview(group_id, enabled, + set_by=state.get("node_user_id", "")) + ctx["chat_link_preview"] = enabled + log.info("Chat link previews for group %s: %s", group_id[:8], + "on" if enabled else "off") + return {"enabled": enabled, "group_id": group_id} # ── Scan settings ──────────────────────────────────────────────────────────── diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 5f38acd..af87f92 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -661,58 +661,110 @@ class Roster: await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE, language, set_by) - # Which folder is the Videos app's entry point for this group — per-group - # (unlike the token/language above), since different groups share - # different trees. Empty/unset means the whole group index, exactly as - # today. - SETTING_VIDEO_ROOT = "video_root" - - async def video_root(self, group_id: str) -> str: - return await self.get_setting(group_id, self.SETTING_VIDEO_ROOT, "") or "" + # ── App directories ───────────────────────────────────────────────────── + # + # Which folder(s) inside the group's shared roots each application uses as + # its entry point. One storage shape for every app, keyed by the app's own + # name, so adding an application needs no change here at all — that is the + # whole point of the plugin architecture (docs/refactor-groups.md §1.6). + # + # Always a JSON list, even for an app that only ever wants one directory. + # Two shapes for one idea is how `video_root` (scalar) and `photo_roots` + # (list) ended up needing separate ops, separate MNP messages and separate + # settings widgets to say the same thing. + # + # Empty/unset means nothing configured yet, and every app reads that as + # "show nothing until an operator has chosen" rather than "the whole group + # index" — the discipline video_root established, kept. + SETTING_APP_DIRS_SUFFIX = "_directories" - async def set_video_root(self, group_id: str, path: str, set_by: str = "") -> str: - await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by) - return path or "" + # What each app's directories used to be stored under, before they were + # one shape. Read as a fallback so an existing node keeps working with no + # migration step: the legacy key is never written again, and the first + # save through the new path leaves it behind. + # Keyed by the *registry* name the app is known by everywhere else + # (apps.js, ALLOWED_APPS, enabled_apps) — which for Music is "music", while + # its old setting was called `audio_root`. One identifier per app, and the + # place the two names meet is this table and nowhere else. + LEGACY_DIR_KEYS = { + "video": ("video_root", "scalar"), + "music": ("audio_root", "scalar"), + "photo": ("photo_roots", "list"), + } - # Same shape as SETTING_VIDEO_ROOT — the Music app's own entry point, - # added later (docs/musicbay.md's original "no root, works over the - # whole shared tree" simplification turned out not to hold up against a - # real messy library: the operator asked for the same scoping Videos - # already had). Empty/unset means Music shows nothing yet, exactly like - # an unset video_root — see daemon.py's enrichment gate. - SETTING_AUDIO_ROOT = "audio_root" + # The name each app's directories are *also* published under, for readers + # that predate the list — the handshake ack's `video_root`, and the group + # context the ack builds from. Derived from the list, never stored beside + # it, so the two cannot disagree; the shape says how to derive it. + CTX_ALIASES = { + "video": ("video_root", "scalar"), + "music": ("audio_root", "scalar"), + "photo": ("photo_roots", "list"), + "chat": ("chat_directory", "scalar"), + } - async def audio_root(self, group_id: str) -> str: - return await self.get_setting(group_id, self.SETTING_AUDIO_ROOT, "") or "" + @classmethod + def app_dirs_key(cls, app_key: str) -> str: + return f"{app_key}{cls.SETTING_APP_DIRS_SUFFIX}" - async def set_audio_root(self, group_id: str, path: str, set_by: str = "") -> str: - await self.set_setting(group_id, self.SETTING_AUDIO_ROOT, path or "", set_by) - return path or "" + @classmethod + def ctx_alias(cls, app_key: str, directories: list[str]) -> tuple[str, object] | None: + """The (name, value) an app's directories are also published under.""" + alias = cls.CTX_ALIASES.get(app_key) + if not alias: + return None + name, shape = alias + if shape == "list": + return name, list(directories) + return name, (directories[0] if directories else "") - # Which folder(s) are the Photos app's entry points for this group — - # a *set*, unlike video_root/audio_root above: a photo library is - # routinely scattered across several unrelated folders (docs/photos.md - # §2.1), so there is no single natural root to pick. Stored the same way - # `enabled_apps` already is (json.dumps(sorted(...))). Empty/unset means - # nothing configured yet — same "show nothing until an operator has - # chosen" discipline video_root/audio_root already established, not - # "the whole group index". - SETTING_PHOTO_ROOTS = "photo_roots" + async def app_directories(self, group_id: str, app_key: str) -> list[str]: + value = await self.get_setting(group_id, self.app_dirs_key(app_key)) + if value is not None: + try: + return [str(p) for p in json.loads(value)] + except (ValueError, TypeError): + return [] - async def photo_roots(self, group_id: str) -> list[str]: - value = await self.get_setting(group_id, self.SETTING_PHOTO_ROOTS) - if value is None: + legacy = self.LEGACY_DIR_KEYS.get(app_key) + if not legacy: return [] + key, shape = legacy + raw = await self.get_setting(group_id, key) + if raw is None: + return [] + if shape == "scalar": + return [raw] if raw else [] try: - return list(json.loads(value)) + return [str(p) for p in json.loads(raw)] except (ValueError, TypeError): return [] - async def set_photo_roots(self, group_id: str, roots: list[str], - set_by: str = "") -> list[str]: - await self.set_setting(group_id, self.SETTING_PHOTO_ROOTS, - json.dumps(sorted(roots)), set_by) - return roots + async def set_app_directories(self, group_id: str, app_key: str, + paths: list[str], set_by: str = "") -> list[str]: + clean = sorted({str(p).strip("/") for p in paths if str(p).strip("/")}) + await self.set_setting(group_id, self.app_dirs_key(app_key), + json.dumps(clean), set_by) + return clean + + # ── Chat ──────────────────────────────────────────────────────────────── + + # Whether the node fetches a page's title/preview when a member posts a + # link. Outbound third-party traffic on the operator's connection, from a + # message they did not write, so it is theirs to switch off — the same + # reasoning as the per-group TMDB switch. Unset means on, because that is + # what the node did before this existed. + SETTING_CHAT_LINK_PREVIEW = "chat_link_preview" + + async def chat_link_preview(self, group_id: str) -> bool: + value = await self.get_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW, "1") + return value != "0" + + async def set_chat_link_preview(self, group_id: str, enabled: bool, + set_by: str = "") -> bool: + await self.set_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW, + "1" if enabled else "0", set_by) + return enabled # 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 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 b99affc..c4d053e 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -76,6 +76,9 @@ from meshbay_common.adminop import ( OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, OP_PHOTO_ROOTS, + OP_APP_DIRECTORIES, + OP_CHAT_DIRECTORY, + OP_CHAT_LINK_PREVIEW, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_ROOT_UPDATE, @@ -484,6 +487,12 @@ class WebRTCPeerSession: self._do_audio_root(msg) elif mtype == MNP.PHOTO_ROOTS: self._do_photo_roots(msg) + elif mtype == MNP.APP_DIRECTORIES: + self._do_app_directories(msg) + elif mtype == MNP.CHAT_DIRECTORY: + self._do_chat_directory(msg) + elif mtype == MNP.CHAT_LINK_PREVIEW: + self._do_chat_link_preview(msg) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.SEASON_META_REQ: @@ -802,6 +811,23 @@ class WebRTCPeerSession: # (docs/photos.md §2.1). Empty means unset (the Photos tab shows # nothing yet). "photo_roots": list(self._group_ctx().get("photo_roots") or []), + # The same three answers in one shape, plus every other app's — + # `<app>_directories`, keyed by the app's registry name, always a + # list. The scalars above are derived from these (daemon.py's + # `_app_directories_ctx`) and kept for MNP 1.0 clients, which can + # represent one folder and never more. A client reading the plural + # form gets all of them. + **{f"{app}_directories": + list(self._group_ctx().get(f"{app}_directories") or []) + for app in ("video", "music", "photo", "chat")}, + # Where chat attachments are written — the singular form, because + # Chat genuinely has one destination. "" means the operator has not + # chosen, and the paperclip says so. + "chat_directory": self._group_ctx().get("chat_directory") or "", + # Whether the node unfurls links members post here. Absent means + # on, which is what it did before this existed. + "chat_link_preview": bool( + self._group_ctx().get("chat_link_preview", True)), # So a client that connects mid-scan shows the indexing state # immediately, instead of waiting for the next periodic # INDEX_PROGRESS push. Never a path or filename — see @@ -2096,6 +2122,150 @@ class WebRTCPeerSession: except Exception: pass + # ── App directories (generic) ──────────────────────────────────────── + + def _do_app_directories(self, msg: dict) -> None: + """ + Which folder(s) an application works over, for any application. + + One handler where there were three near-identical ones (`video_root`, + `audio_root`, `photo_roots`) differing only in the key they wrote and + whether they carried a string or a list. Those three still exist for + clients that speak them; nothing new is added beside them. + + `app` must be one this node knows (`ALLOWED_APPS`) — a client-supplied + key is otherwise a way to write arbitrary rows into `group_settings`. + The paths are checked by `ops._validate_app_dirs`, which runs after the + signature: this is a settings change, not a capability, so refusing + early here would be a courtesy rather than the control. + """ + app = str(msg.get("app", "")).strip() + dirs = msg.get("directories") + if app not in self.ALLOWED_APPS: + self._send({"type": "error", "detail": f"Unknown app {app!r}"}) + return + if not isinstance(dirs, list) or not all(isinstance(d, str) for d in dirs): + self._send({"type": "error", + "detail": "Missing or invalid 'directories'"}) + return + clean = sorted({d.strip("/") for d in dirs if d.strip("/")}) + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # The app is in the subject, not only the paths: an operator shown + # "Media/Films" alone cannot tell which application is about to be + # pointed at it, and two apps' challenges would be indistinguishable. + self._issue_admin_challenge( + OP_APP_DIRECTORIES, f"{app}:{','.join(clean)}") + + async def _admin_exec_app_directories( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + app, _, joined = pending["subject"].partition(":") + dirs = joined.split(",") if joined else [] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"app_directories:{pending['subject']}") + return + try: + result = await self._run_op( + ops.set_app_directories, self._group_id or "", app, dirs) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("app_directories", pending["subject"]) + self._broadcast_to_group({"type": MNP.APP_DIRECTORIES_ACK, + "v": MNP_VERSION, "app": app, + "directories": result["directories"]}) + + # ── Chat ───────────────────────────────────────────────────────────── + + def _do_chat_directory(self, msg: dict) -> None: + """ + Where chat attachments are written. + + Unlike every other app directory this one is a destination, so it has + to be on a read-write root — checked by `ops.set_chat_directory` after + the signature, which is where the refusal actually lives. + """ + path = msg.get("path") + if not isinstance(path, str): + self._send({"type": "error", "detail": "Missing or invalid 'path'"}) + return + path = path.strip("/") + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_CHAT_DIRECTORY, path) + + async def _admin_exec_chat_directory( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + path = pending["subject"] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"chat_directory:{path}") + return + try: + await self._run_op( + ops.set_chat_directory, self._group_id or "", path) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("chat_directory", path) + self._broadcast_to_group( + {"type": MNP.CHAT_DIRECTORY_ACK, "v": MNP_VERSION, "path": path}) + + def _do_chat_link_preview(self, msg: dict) -> None: + """ + Whether the node fetches a page's title and image when a member posts + a link — outbound traffic on the operator's connection, from a message + they did not write, so it is signed like everything else that decides + what leaves this machine. + """ + enabled = msg.get("enabled") + if not isinstance(enabled, bool): + self._send({"type": "error", "detail": "Missing or invalid 'enabled'"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge( + OP_CHAT_LINK_PREVIEW, "on" if enabled else "off") + + async def _admin_exec_chat_link_preview( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + enabled = 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"chat_link_preview:{pending['subject']}") + return + try: + await self._run_op( + ops.set_chat_link_preview, self._group_id or "", enabled) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("chat_link_preview", pending["subject"]) + self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK, + "v": MNP_VERSION, "enabled": enabled}) + + def _broadcast_to_group(self, notice: dict) -> None: + """ + Tell everyone connected to this group about a setting that changed. + + Enforcement never depends on this reaching them — the node is what + refuses — but a control that stays on screen until the next + reconnection is a control people use. + """ + for _uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + def _do_musicbrainz_enabled(self, msg: dict) -> None: """ Whether MusicBrainz lookups run for this group at all. Per-group @@ -3734,6 +3904,17 @@ class WebRTCPeerSession: """ url = msg.get("url") key = url if isinstance(url, str) else "" + + # Checked before the cache, not after: the operator turning previews + # off has to stop serving the ones already fetched too, or the setting + # takes effect only for links nobody has posted yet. Refused as an + # ordinary miss — the client shows the bare link, which is exactly what + # "no preview" looks like for a page that has none. + if not self._group_ctx().get("chat_link_preview", True): + self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION, + "url": key, "ok": False}) + return + cached = _link_preview_cache_get(key) if cached is not None: self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION}) @@ -4155,6 +4336,15 @@ class WebRTCPeerSession: elif pending["op"] == OP_ROOT_REMOVE: self._spawn( self._admin_exec_root_remove(pending, transcript, sig_bytes)) + elif pending["op"] == OP_APP_DIRECTORIES: + self._spawn( + self._admin_exec_app_directories(pending, transcript, sig_bytes)) + elif pending["op"] == OP_CHAT_DIRECTORY: + self._spawn( + self._admin_exec_chat_directory(pending, transcript, sig_bytes)) + elif pending["op"] == OP_CHAT_LINK_PREVIEW: + self._spawn( + self._admin_exec_chat_link_preview(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_UPDATE: self._spawn( self._admin_exec_root_update(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/tests/test_app_directories.py b/packages/meshbay-node/tests/test_app_directories.py new file mode 100644 index 0000000..3ede1b6 --- /dev/null +++ b/packages/meshbay-node/tests/test_app_directories.py @@ -0,0 +1,292 @@ +""" +One shape for every application's directories. + +`video_root` (a string), `audio_root` (a string) and `photo_roots` (a list) +said the same thing three ways, and each needed its own op, its own MNP message +and its own settings widget. They are one function keyed by the app's own name +now, which is what lets an application be added without touching this layer at +all — the whole claim of the plugin architecture. + +Two properties are new rather than moved, and both matter more than the tidying: + +* **the paths are validated.** The setters this replaces accepted anything. A + typo, or a path left behind when a root was removed, was stored happily and + then matched no entry — an app showing an empty tab, with nothing to + distinguish "misconfigured" from "no files yet". The moment of setting is the + only one where the operator is present to be told; +* **the legacy scalar is derived, never stored.** `video_root` still rides on + the handshake ack for MNP 1.0 clients. Kept as a second stored value it would + drift from the list within one run — the shape of bug that reads as "it works + after a restart". +""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_node import ops +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roots import RootSet +from meshbay_node.roster import Roster + +pytestmark = pytest.mark.asyncio + +GROUP = "g" * 32 + + +async def _state(tmp_path: Path, *, writable: bool = True) -> tuple[dict, Roster]: + media = tmp_path / "Media" + (media / "Films").mkdir(parents=True) + (media / "Albums").mkdir() + published = tmp_path / "Published" + published.mkdir() + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + roots = RootSet.build([ + {"path": str(media), "writable": writable}, + {"path": str(published)}, + ]) + state = { + "roster": roster, + "node_user_id": "operator", + "groups_ctx": {GROUP: {"index": index, "roots": roots}}, + "config": SimpleNamespace(groups=[SimpleNamespace(id=GROUP, roots=[])]), + } + return state, roster + + +# ── One function, any app ──────────────────────────────────────────────────── + +async def test_an_app_nobody_wrote_code_for_stores_its_directories(tmp_path): + """ + The point of the generic pair. Nothing in ops.py, roster.py or the daemon + names this app, and it round-trips anyway — which is the difference between + a plugin architecture and a list of special cases. + """ + state, roster = await _state(tmp_path) + try: + await ops.set_app_directories(state, GROUP, "helloworld", ["Media/Films"]) + assert await roster.app_directories(GROUP, "helloworld") == ["Media/Films"] + finally: + await roster.close() + + +async def test_directories_are_deduplicated_and_ordered(tmp_path): + """ + The stored form is what the operator signs a subject built from, on both + sides. Two clients sending the same set in different orders must produce + the same bytes, or one of them refuses to sign its own request. + """ + state, roster = await _state(tmp_path) + try: + out = await ops.set_app_directories( + state, GROUP, "video", ["Media/Films", "Media", "Media/Films"]) + assert out["directories"] == ["Media", "Media/Films"] + finally: + await roster.close() + + +async def test_a_single_directory_app_stores_a_one_element_list(tmp_path): + state, roster = await _state(tmp_path) + try: + out = await ops.set_app_directory(state, GROUP, "chat", "Media/Films", + require_writable=True) + assert out["path"] == "Media/Films" + assert await roster.app_directories(GROUP, "chat") == ["Media/Films"] + + cleared = await ops.set_app_directory(state, GROUP, "chat", "") + assert cleared["path"] == "" + assert await roster.app_directories(GROUP, "chat") == [] + finally: + await roster.close() + + +# ── Validation ─────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("bad", [ + "Nowhere", "Nowhere/Deeper", "/etc", "Media/../../etc", "..", +]) +async def test_a_directory_outside_every_root_is_refused(tmp_path, bad): + state, roster = await _state(tmp_path) + try: + with pytest.raises(ops.OpError): + await ops.set_app_directories(state, GROUP, "video", [bad]) + assert await roster.app_directories(GROUP, "video") == [] + finally: + await roster.close() + + +async def test_a_read_only_root_is_refused_where_writability_is_required(tmp_path): + """ + Chat's directory is a destination, not a view. Storing one on a read-only + root would produce a paperclip that fails at the moment somebody uses it, + which is the failure the RO/RW model exists to move earlier. + """ + state, roster = await _state(tmp_path) + try: + with pytest.raises(ops.OpError, match="read-only"): + await ops.set_app_directory(state, GROUP, "chat", "Published", + require_writable=True) + # The same path is fine for an app that only reads it. + await ops.set_app_directories(state, GROUP, "video", ["Published"]) + assert await roster.app_directories(GROUP, "video") == ["Published"] + finally: + await roster.close() + + +async def test_a_directory_on_an_unplugged_drive_can_still_be_configured(tmp_path): + """ + Deliberately *not* `RootSet.resolve()`, which also refuses a root that is + currently unavailable. An operator must be able to point an app at a + library on a drive they have ejected — what is checked is the shape, which + does not change with what happens to be mounted. + """ + state, roster = await _state(tmp_path) + roots = state["groups_ctx"][GROUP]["roots"] + roots.roots[0].available = False + try: + out = await ops.set_app_directories(state, GROUP, "video", ["Media/Films"]) + assert out["directories"] == ["Media/Films"] + finally: + await roster.close() + + +# ── The derived scalar ─────────────────────────────────────────────────────── + +async def test_the_legacy_scalar_follows_the_list_in_the_live_context(tmp_path): + """ + `video_root` rides on the handshake ack for MNP 1.0 clients and is read + from the group context. Left behind by a save, it would disagree with the + list until the next restart. + """ + state, roster = await _state(tmp_path) + ctx = state["groups_ctx"][GROUP] + try: + await ops.set_app_directories(state, GROUP, "video", + ["Media/Films", "Media/Albums"]) + assert ctx["video_directories"] == ["Media/Albums", "Media/Films"] + assert ctx["video_root"] == "Media/Albums", ( + "the scalar must be the first of the list, not a stale value") + + await ops.set_app_directories(state, GROUP, "video", []) + assert ctx["video_root"] == "" + finally: + await roster.close() + + +async def test_the_photo_alias_stays_a_list_and_chat_stays_a_string(tmp_path): + """The alias table has to carry the shape, not just the name.""" + state, roster = await _state(tmp_path) + ctx = state["groups_ctx"][GROUP] + try: + await ops.set_app_directories(state, GROUP, "photo", + ["Media/Films", "Media/Albums"]) + assert ctx["photo_roots"] == ["Media/Albums", "Media/Films"] + await ops.set_app_directory(state, GROUP, "chat", "Media", + require_writable=True) + assert ctx["chat_directory"] == "Media" + finally: + await roster.close() + + +# ── Reading what an older node stored ──────────────────────────────────────── + +async def test_an_existing_video_root_is_read_without_a_migration(tmp_path): + """ + A node upgraded into this reads its old key until the first save through + the new path. Requiring a migration script to run before the Videos tab + works again would be a step nobody performs on the machine where it + matters. + """ + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_setting(GROUP, "video_root", "Media/Films") + assert await roster.app_directories(GROUP, "video") == ["Media/Films"] + + await roster.set_setting(GROUP, "audio_root", "Media/Albums") + assert await roster.app_directories(GROUP, "music") == ["Media/Albums"] + + await roster.set_setting(GROUP, "photo_roots", '["A", "B"]') + assert await roster.app_directories(GROUP, "photo") == ["A", "B"] + finally: + await roster.close() + + +async def test_an_empty_legacy_value_means_nothing_configured(tmp_path): + """`video_root = ""` was how "unset" was spelled; it must not become `[""]`.""" + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_setting(GROUP, "video_root", "") + assert await roster.app_directories(GROUP, "video") == [] + finally: + await roster.close() + + +async def test_the_new_key_wins_over_the_legacy_one(tmp_path): + """ + Both present is a group saved once through the new path. Reading the old + key there would undo that save on every load. + """ + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_setting(GROUP, "video_root", "Old/Place") + await roster.set_app_directories(GROUP, "video", ["New/Place"]) + assert await roster.app_directories(GROUP, "video") == ["New/Place"] + finally: + await roster.close() + + +async def test_an_app_with_no_legacy_name_simply_has_none(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.app_directories(GROUP, "helloworld") == [] + finally: + await roster.close() + + +# ── Chat's own settings ────────────────────────────────────────────────────── + +async def test_link_previews_default_on_and_survive_a_restart(tmp_path): + """ + Absent means on, because that is what the node did before the switch + existed — an upgrade must not silently change what a group's chat does. + """ + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.chat_link_preview(GROUP) is True + await roster.set_chat_link_preview(GROUP, False, set_by="op") + assert await roster.chat_link_preview(GROUP) is False + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.chat_link_preview(GROUP) is False + assert await reopened.chat_link_preview("other") is True, ( + "one group's setting must not answer for another") + finally: + await reopened.close() + + +async def test_setting_link_previews_updates_the_live_context(tmp_path): + """ + The unfurl handler reads the context, not the database — it runs per + message and a round trip there would be absurd. So the two are kept in + step by the op that changes it. + """ + state, roster = await _state(tmp_path) + try: + await ops.set_chat_link_preview(state, GROUP, False) + assert state["groups_ctx"][GROUP]["chat_link_preview"] is False + finally: + await roster.close() diff --git a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py index deab1bd..5a8f9b1 100644 --- a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py +++ b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py @@ -102,7 +102,7 @@ async def test_only_entries_under_the_configured_root_are_enriched(tmp_path): daemon = await _make_daemon(tmp_path, shared, group_id) try: - await daemon._roster.set_audio_root(group_id, "shared/Music", set_by="op") + await daemon._roster.set_app_directories(group_id, "music", ["shared/Music"], set_by="op") indexer = DirectoryIndexer( roots=one_root(shared), group_id=group_id, sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) @@ -142,8 +142,10 @@ async def test_setting_the_audio_root_sweeps_what_it_already_contains(tmp_path): # this file all along. state = { "roster": daemon._roster, - "groups_ctx": {group_id: {}}, - "enrich_audio_root_fn": daemon._enrich_audio_root_now, + # Real roots, because ops now refuses a directory that is not + # inside one — the per-app setters this replaced validated nothing. + "groups_ctx": {group_id: {"roots": one_root(shared)}}, + "enrich_app_dirs_fns": {"music": daemon._enrich_audio_root_now}, } await ops.set_audio_root(state, group_id, "shared/Music") await asyncio.sleep(0.05) # let the fire-and-forget sweep actually run @@ -197,8 +199,10 @@ async def test_the_same_file_shared_into_two_groups_enriches_in_both(tmp_path): "the fixture itself must produce identical content hashes — " "otherwise this test isn't exercising the collision at all") - await daemon._roster.set_audio_root(group_a, "shared_a/Music", set_by="op") - await daemon._roster.set_audio_root(group_b, "shared_b/Music", set_by="op") + await daemon._roster.set_app_directories( + group_a, "music", ["shared_a/Music"], set_by="op") + await daemon._roster.set_app_directories( + group_b, "music", ["shared_b/Music"], set_by="op") await daemon._enrich_new_audio_entries(indexer_a, list(indexer_a.index.entries)) await daemon._enrich_new_audio_entries(indexer_b, list(indexer_b.index.entries)) diff --git a/packages/meshbay-node/tests/test_audio_root_policy.py b/packages/meshbay-node/tests/test_audio_root_policy.py index 576c08a..e2e9254 100644 --- a/packages/meshbay-node/tests/test_audio_root_policy.py +++ b/packages/meshbay-node/tests/test_audio_root_policy.py @@ -125,17 +125,20 @@ async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): roster = Roster(db_path=tmp_path / "roster.db") await roster.open() try: - assert await roster.audio_root("g1") == "", "absent must mean unset" - await roster.set_audio_root("g1", "shared/Music", set_by="op") - assert await roster.audio_root("g1") == "shared/Music" + assert await roster.app_directories("g1", "music") == [], ( + "absent must mean unset") + await roster.set_app_directories("g1", "music", ["shared/Music"], + set_by="op") + assert await roster.app_directories("g1", "music") == ["shared/Music"] finally: await roster.close() reopened = Roster(db_path=tmp_path / "roster.db") await reopened.open() try: - assert await reopened.audio_root("g1") == "shared/Music" - assert await reopened.audio_root("g2") == "", "one group's setting must not answer for another" + assert await reopened.app_directories("g1", "music") == ["shared/Music"] + assert await reopened.app_directories("g2", "music") == [], ( + "one group's setting must not answer for another") finally: await reopened.close() @@ -239,7 +242,7 @@ async def test_a_real_signed_save_persists_and_survives_a_fresh_roster_read(tmp_ assert session._ctx["groups"][GROUP]["audio_root"] == "shared/Music", ( "the live in-memory context must reflect the new root immediately") - assert await roster.audio_root(GROUP) == "shared/Music", ( + assert await roster.app_directories(GROUP, "music") == ["shared/Music"], ( "the same Roster instance must read back what it just wrote") finally: await roster.close() @@ -249,7 +252,7 @@ async def test_a_real_signed_save_persists_and_survives_a_fresh_roster_read(tmp_ # actually answers "does it survive a reload". reopened = await open_roster(tmp_path) try: - assert await reopened.audio_root(GROUP) == "shared/Music", ( + assert await reopened.app_directories(GROUP, "music") == ["shared/Music"], ( "a freshly-opened Roster against the same db file must see the " "committed value — anything else means the write was never " "durable in the first place") diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py index 7a77368..f6761d9 100644 --- a/packages/meshbay-node/tests/test_rename_reenrichment.py +++ b/packages/meshbay-node/tests/test_rename_reenrichment.py @@ -37,8 +37,8 @@ def _free_port() -> int: class _StubRoster: - async def video_root(self, group_id): - return "shared" + async def app_directories(self, group_id, app_key): + return ["shared"] if app_key == "video" else [] class _SpyEnricher: diff --git a/packages/meshbay-node/tests/test_root_eject.py b/packages/meshbay-node/tests/test_root_eject.py index 0ec36a4..f57fb92 100644 --- a/packages/meshbay-node/tests/test_root_eject.py +++ b/packages/meshbay-node/tests/test_root_eject.py @@ -17,7 +17,6 @@ exactly what an operator does after noticing a drive fell off — silently undid the eject, and the next scan read an empty mount point as an erased library. """ -import asyncio from pathlib import Path import pytest diff --git a/packages/meshbay-node/tests/test_startup_scan_enrichment.py b/packages/meshbay-node/tests/test_startup_scan_enrichment.py index 65b9728..d2cbc3e 100644 --- a/packages/meshbay-node/tests/test_startup_scan_enrichment.py +++ b/packages/meshbay-node/tests/test_startup_scan_enrichment.py @@ -60,8 +60,9 @@ async def test_a_file_already_on_disk_at_startup_gets_enrichment_scheduled(tmp_p data_dir=tmp_path / "data", ) class _StubRoster: - async def video_root(self, group_id): - return "shared" # the root itself, i.e. "enrich the whole thing" + async def app_directories(self, group_id, app_key): + # The root itself, i.e. "enrich the whole thing". + return ["shared"] if app_key == "video" else [] daemon = NodeDaemon(config) daemon._broadcast_coalesce_secs = 0.01 # real value would make this test wait 0.5s diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py index 9cfb819..b88ecaf 100644 --- a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py +++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py @@ -99,7 +99,7 @@ async def test_only_entries_under_the_configured_root_are_enriched(tmp_path): daemon = await _make_daemon(tmp_path, shared, group_id) try: - await daemon._roster.set_video_root(group_id, "shared/Movies", set_by="op") + await daemon._roster.set_app_directories(group_id, "video", ["shared/Movies"], set_by="op") indexer = DirectoryIndexer( roots=one_root(shared), group_id=group_id, sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) @@ -139,8 +139,10 @@ async def test_setting_the_video_root_sweeps_what_it_already_contains(tmp_path): # this file all along. state = { "roster": daemon._roster, - "groups_ctx": {group_id: {}}, - "enrich_video_root_fn": daemon._enrich_video_root_now, + # Real roots, because ops now refuses a directory that is not + # inside one — the per-app setters this replaced validated nothing. + "groups_ctx": {group_id: {"roots": one_root(shared)}}, + "enrich_app_dirs_fns": {"video": daemon._enrich_video_root_now}, } await ops.set_video_root(state, group_id, "shared/Movies") await asyncio.sleep(0.05) # let the fire-and-forget sweep actually run diff --git a/packages/meshbay-node/tests/test_video_root_policy.py b/packages/meshbay-node/tests/test_video_root_policy.py index 8cc1540..8d8c45a 100644 --- a/packages/meshbay-node/tests/test_video_root_policy.py +++ b/packages/meshbay-node/tests/test_video_root_policy.py @@ -126,16 +126,19 @@ async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): roster = Roster(db_path=tmp_path / "roster.db") await roster.open() try: - assert await roster.video_root("g1") == "", "absent must mean the whole group index" - await roster.set_video_root("g1", "shared/Movies", set_by="op") - assert await roster.video_root("g1") == "shared/Movies" + assert await roster.app_directories("g1", "video") == [], ( + "absent must mean nothing configured") + await roster.set_app_directories("g1", "video", ["shared/Movies"], + set_by="op") + assert await roster.app_directories("g1", "video") == ["shared/Movies"] finally: await roster.close() reopened = Roster(db_path=tmp_path / "roster.db") await reopened.open() try: - assert await reopened.video_root("g1") == "shared/Movies" - assert await reopened.video_root("g2") == "", "one group's setting must not answer for another" + assert await reopened.app_directories("g1", "video") == ["shared/Movies"] + assert await reopened.app_directories("g2", "video") == [], ( + "one group's setting must not answer for another") finally: await reopened.close() |