diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 18:16:57 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 18:16:57 +0200 |
| commit | 85a2ec47b7ad334208a3dbb091fadccc7631785c (patch) | |
| tree | a75ff6c12b8229d2dc8082bb36ebc5b3de73b706 | |
| parent | 4e6d6573003b06dd58268602d50a98988d4ce3d0 (diff) | |
| download | meshbay-85a2ec47b7ad334208a3dbb091fadccc7631785c.tar.gz | |
feat(node): Phase 2 server side — one directory setting for every app
`video_root` (a string), `audio_root` (a string) and `photo_roots` (a list)
said the same thing three ways: three roster accessors, three ops, three MNP
messages, three admin-op subjects. They become `set_app_directories(app_key,
paths)` and its single-directory wrapper, stored under `<app>_directories` and
keyed by the app's registry name — so an application can be added without
touching this layer, which is the whole claim of the plugin architecture.
The three old names still work. Their MNP messages are handled, and the roster
falls back to the old key when the new one is unset, so a node upgraded into
this keeps working with no migration step — the plan called for a script, and
a script nobody runs on the machine where it matters is worse than a fallback.
Two things are new rather than moved:
The paths are validated. The setters this replaces accepted anything, so a
typo — or a path left behind when a root was removed — was stored happily and
then matched no entry, leaving an app showing an empty tab with nothing to
distinguish "misconfigured" from "no files yet". Deliberately not
`RootSet.resolve()`: that also refuses a currently-unavailable root, and an
operator must be able to point an app at a library on a drive they ejected.
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, which reads as "it works after a restart".
Also here: chat's own two settings (a directory, which must be on a read-write
root because it is a destination rather than a view, and a link-preview switch
gating the unfurl path — checked before the cache, or turning it off would
still serve every preview already fetched), the `app_directories`,
`chat_directory` and `chat_link_preview` MNP messages, the plural
`<app>_directories` on the handshake ack, and `music` as the app's one
identifier where storage said `audio` and the registry said `music`.
The Music enricher now resolves a boundary per configured directory rather
than one for the group: with several, a single boundary is wrong for all but
one of them, and for Music that is the difference between reading a folder as
an artist and reading it as a release.
Suite: 11 failures, all pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
14 files changed, 875 insertions, 173 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 1d2e2b2..9a56934 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -106,6 +106,12 @@ OP_AUDIO_ROOT = "audio_root" OP_PHOTO_ROOTS = "photo_roots" OP_ROOT_ADD = "root_add" OP_ROOT_REMOVE = "root_remove" +# One op for every application's directories. The subject is +# "<app>:<comma-joined sorted paths>" so what the operator is shown before +# signing names both the app and the outcome — "video_root" alone said neither. +OP_APP_DIRECTORIES = "app_directories" +OP_CHAT_DIRECTORY = "chat_directory" +OP_CHAT_LINK_PREVIEW = "chat_link_preview" OP_ROOT_UPDATE = "root_update" OP_ROOT_EJECT = "root_eject" OP_ROOT_PLUG = "root_plug" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 7ee5df1..689adbf 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -164,6 +164,18 @@ class MNP: ROOT_ADD_ACK = "root_add_ack" # node → operator: confirmed ROOT_REMOVE = "root_remove" # operator → node: remove a root from a group ROOT_REMOVE_ACK = "root_remove_ack" # node → operator: confirmed + # One message for every application's directories, keyed by the app's own + # name — adding an app adds no message type. VIDEO_ROOT / AUDIO_ROOT / + # PHOTO_ROOTS above are the same instruction under three earlier names and + # are still handled, for clients that predate this. + APP_DIRECTORIES = "app_directories" # operator → node: an app's folder(s) + APP_DIRECTORIES_ACK = "app_directories_ack" + # Chat's own two: where attachments are written (a destination, so it must + # be a read-write root), and whether the node unfurls links members post. + CHAT_DIRECTORY = "chat_directory" + CHAT_DIRECTORY_ACK = "chat_directory_ack" + CHAT_LINK_PREVIEW = "chat_link_preview" + CHAT_LINK_PREVIEW_ACK = "chat_link_preview_ack" ROOT_UPDATE = "root_update" # operator → node: change writable/removable on a root ROOT_UPDATE_ACK = "root_update_ack" ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected 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() |