aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-10 17:49:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-10 17:49:58 +0200
commit07ff8b4f6143039fcc74b8cf7c423282bce093c1 (patch)
tree89e095aeef9f5ad0bcbe7b3e6cfdc67d36bcbba8 /packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
parent1e6f3861a1570029897a30b42121836fd03565c1 (diff)
downloadmeshbay-07ff8b4f6143039fcc74b8cf7c423282bce093c1.tar.gz
refactor(mnp)!: one operation for an app's folders, not one per app
`video_root`, `audio_root` and `photo_roots` are gone — the messages, the signed operations, the handlers, the `ops` wrappers, the three scalars on the handshake ack, and the client's handlers for their acks. `app_directories` does the same thing for every application, keyed by the app's own registry name, and it is what the SPA has been sending. The three were the same instruction three times, differing only in the key they wrote and whether they carried a string or a list. That shape is what made adding an application mean adding a message type, an op, a handler and a widget; it also meant three validation paths, and the older ones validated nothing — a typo was stored and then quietly matched no entry, an app showing an empty tab with no way to tell "misconfigured" from "no files yet". **What stays, and why.** `Roster.LEGACY_DIR_KEYS` still reads `video_root` and friends out of `group_settings`: that is a key on an operator's disk, not on the wire, and a node upgraded into this must find its own configuration. The Search page still reads its own older cache keys, for the same reason — the cache outlives a deploy. `CTX_ALIASES` keeps only `chat`, which is the one app whose second name something still reads. The two per-app policy test files go with the messages. What only they held — the real challenge/response path from message to database, which no other test exercises — is retargeted at `app_directories` in `test_app_directories_signed.py`, and the handler's own refusals (unknown app, malformed `directories`, nobody to authorize it) join `test_app_directories.py`. Node and common suites 1368 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py211
1 files changed, 12 insertions, 199 deletions
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 b3618b5..b2374d3 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -72,12 +72,9 @@ from meshbay_common.adminop import (
OP_TRANSFER_LIMITS,
OP_TMDB_CONFIG,
OP_TMDB_ENABLED,
- OP_VIDEO_ROOT,
OP_TMDB_OVERRIDE,
OP_TMDB_REMATCH,
OP_MUSICBRAINZ_ENABLED,
- OP_AUDIO_ROOT,
- OP_PHOTO_ROOTS,
OP_APP_DIRECTORIES,
OP_CHAT_DIRECTORY,
OP_CHAT_EPOCH,
@@ -577,12 +574,6 @@ class WebRTCPeerSession:
self._do_tmdb_config(msg)
elif mtype == MNP.TMDB_ENABLED:
self._do_tmdb_enabled(msg)
- elif mtype == MNP.VIDEO_ROOT:
- self._do_video_root(msg)
- elif mtype == MNP.AUDIO_ROOT:
- 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:
@@ -870,7 +861,7 @@ class WebRTCPeerSession:
# below is sealed under a GEK-derived subkey, which gives it an
# authentication tag from a key the hub does not hold. Until MNP 1.0 the
# signed transcript named no ack field at all, so is_node_admin,
- # enabled_apps, video_root and the rest were authenticated by the DTLS
+ # enabled_apps, the app directories and the rest were authenticated by DTLS
# channel and nothing else.
config = {
"is_node_admin": self._is_node_admin(),
@@ -879,14 +870,10 @@ class WebRTCPeerSession:
# setting (or one whose context has not loaded it yet) hides
# nothing.
"enabled_apps": list(self._group_ctx().get("enabled_apps") or []),
- # Which folder the Videos app treats as its entry point for
- # this group — "" means the whole group index.
- "video_root": self._group_ctx().get("video_root") or "",
- # Per-group (2026-08-24 — used to be node-wide), same "read once,
- # kept current in place by the signed op" shape as video_root
- # above — surfaced here rather than only via tmdb_enabled_ack so
- # a client that connects after the operator already configured
- # it does not have to wait for a live change to find out.
+ # Read once and kept current in place by the signed op, and
+ # surfaced here rather than only via tmdb_enabled_ack, so a client
+ # that connects after the operator configured it does not have to
+ # wait for a live change to find out.
"tmdb_enabled": bool(self._group_ctx().get("tmdb_enabled", True)),
# Token/language stay node-wide (one shared credential/cache) —
# via daemon_state, kept current by tmdb_config_ack.
@@ -898,15 +885,6 @@ class WebRTCPeerSession:
# fields above. No language field: MusicBrainz search doesn't
# take one the way TMDB does.
"musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)),
- # Which folder the Music app treats as its entry point for this
- # group — same shape as video_root above, "" means unset (the
- # Music tab shows nothing yet).
- "audio_root": self._group_ctx().get("audio_root") or "",
- # Which folder(s) the Photos app treats as its entry points for
- # this group — a *list*, unlike video_root/audio_root above
- # (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
@@ -2203,7 +2181,7 @@ class WebRTCPeerSession:
"""
Whether TMDB lookups run for this group at all. Per-group, unlike
tmdb_config's token/language — see ops.set_tmdb_enabled. Signed like
- video_root: it decides whether this group's members' Videos tab ever
+ app_directories: it decides whether this group's members' Videos tab ever
makes outbound TMDB traffic.
"""
enabled = msg.get("enabled")
@@ -2237,172 +2215,16 @@ class WebRTCPeerSession:
except Exception:
pass
- def _do_video_root(self, msg: dict) -> None:
- """
- Which folder (possibly a subfolder of a shared root) the Videos app
- treats as its entry point for this group. Signed like apps_enabled:
- it decides what every member's Videos tab shows.
-
- An empty path is always accepted (it means "the whole group index",
- today's behaviour). A non-empty path must resolve to a real,
- currently-readable directory — validated against the group's own
- roots the same way directory creation/deletion already is, so a
- stale or mistyped path is refused before a signature is even asked
- for.
- """
- path = msg.get("path")
- if not isinstance(path, str):
- self._send({"type": "error", "detail": "Missing or invalid 'path'"})
- return
- path = path.strip("/")
- if path:
- ctx = self._group_ctx()
- resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None
- if not resolved or not resolved.is_dir():
- self._send({"type": "error", "detail": "Not a directory in this group"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- self._issue_admin_challenge(OP_VIDEO_ROOT, path)
-
- async def _admin_exec_video_root(
- 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"video_root:{path}")
- return
- try:
- await self._run_op(ops.set_video_root, self._group_id or "", path)
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("video_root", path)
-
- notice = {"type": MNP.VIDEO_ROOT_ACK, "v": MNP_VERSION, "path": path}
- for uid, session in list(self._peer_registry().items()):
- try:
- session._send(notice)
- except Exception:
- pass
-
- def _do_audio_root(self, msg: dict) -> None:
- """Same shape as _do_video_root above — the Music app's own entry point."""
- path = msg.get("path")
- log.debug("audio_root request user=%s path=%r",
- (self._user_id or "?")[:8], path)
- if not isinstance(path, str):
- self._send({"type": "error", "detail": "Missing or invalid 'path'"})
- return
- path = path.strip("/")
- if path:
- ctx = self._group_ctx()
- resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None
- if not resolved or not resolved.is_dir():
- self._send({"type": "error", "detail": "Not a directory in this group"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- self._issue_admin_challenge(OP_AUDIO_ROOT, path)
-
- async def _admin_exec_audio_root(
- 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"audio_root:{path}")
- return
- try:
- await self._run_op(ops.set_audio_root, self._group_id or "", path)
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("audio_root", path)
-
- notice = {"type": MNP.AUDIO_ROOT_ACK, "v": MNP_VERSION, "path": path}
- for uid, session in list(self._peer_registry().items()):
- try:
- session._send(notice)
- except Exception:
- pass
-
- def _do_photo_roots(self, msg: dict) -> None:
- """
- Which folder(s) the Photos app treats as its entry points for this
- group (docs/photos.md §2.1) — a *set*, replaced whole in one signed
- op, same shape as apps_enabled rather than one op per root the way
- video_root/audio_root are single values.
-
- An empty list is always accepted (nothing configured yet, today's
- "Photos shows nothing" state). Every non-empty path must resolve to
- a real, currently-readable directory, and no root may be nested
- inside another in the same submitted set — both checked, and
- refused, before a signature is ever asked for, same principle as
- video_root's path check and apps_enabled's "empty set refused up
- front".
- """
- roots = msg.get("roots")
- if not isinstance(roots, list) or not all(isinstance(r, str) for r in roots):
- self._send({"type": "error", "detail": "Missing or invalid 'roots'"})
- return
- roots = sorted({r.strip("/") for r in roots if r.strip("/")})
- ctx = self._group_ctx()
- for path in roots:
- resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None
- if not resolved or not resolved.is_dir():
- self._send({"type": "error",
- "detail": f"Not a directory in this group: {path}"})
- return
- # Case-insensitive nesting check (§6.8) — a root may not be a folder
- # itself sitting inside another root in the same set.
- folded = [r.casefold() for r in roots]
- for i, a in enumerate(folded):
- for j, b in enumerate(folded):
- if i != j and (a == b or a.startswith(b + "/")):
- self._send({"type": "error",
- "detail": f"Root nested inside another: {roots[i]}"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- self._issue_admin_challenge(OP_PHOTO_ROOTS, ",".join(roots))
-
- async def _admin_exec_photo_roots(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- roots = pending["subject"].split(",") if pending["subject"] else []
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed", f"photo_roots:{pending['subject']}")
- return
- try:
- await self._run_op(ops.set_photo_roots, self._group_id or "", roots)
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("photo_roots", pending["subject"])
-
- notice = {"type": MNP.PHOTO_ROOTS_ACK, "v": MNP_VERSION, "roots": roots}
- for uid, session in list(self._peer_registry().items()):
- try:
- session._send(notice)
- 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.
+ One handler for every application, keyed by the app's own name: adding
+ an application adds no message type, and there is no per-app handler
+ differing only in the key it writes and whether it carries a string or
+ a list.
`app` must be one this node knows (`ALLOWED_APPS`) — a client-supplied
key is otherwise a way to write arbitrary rows into `group_settings`.
@@ -2651,7 +2473,7 @@ class WebRTCPeerSession:
"""
Whether MusicBrainz lookups run for this group at all. Per-group
from the start (docs/musicbay.md §3.2/§6) — signed like
- tmdb_enabled/video_root: it decides whether this group's members'
+ tmdb_enabled: it decides whether this group's members'
Music tab ever makes outbound MusicBrainz traffic.
"""
enabled = msg.get("enabled")
@@ -4136,7 +3958,7 @@ class WebRTCPeerSession:
def _do_tmdb_override(self, msg: dict) -> None:
"""
An operator correcting a wrong automatic TMDB match. Signed like
- video_root/tmdb_config: it replaces what every member sees for a
+ app_directories/tmdb_config: it replaces what every member sees for a
show/movie, node-wide (media_cache is shared, not per-viewer).
For a **show**, applied to every entry sharing the representative
@@ -5317,9 +5139,6 @@ class WebRTCPeerSession:
elif pending["op"] == OP_TMDB_ENABLED:
self._spawn(
self._admin_exec_tmdb_enabled(pending, transcript, sig_bytes))
- elif pending["op"] == OP_VIDEO_ROOT:
- self._spawn(
- self._admin_exec_video_root(pending, transcript, sig_bytes))
elif pending["op"] == OP_TMDB_OVERRIDE:
self._spawn(
self._admin_exec_tmdb_override(pending, transcript, sig_bytes))
@@ -5329,12 +5148,6 @@ class WebRTCPeerSession:
elif pending["op"] == OP_MUSICBRAINZ_ENABLED:
self._spawn(
self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes))
- elif pending["op"] == OP_AUDIO_ROOT:
- self._spawn(
- self._admin_exec_audio_root(pending, transcript, sig_bytes))
- elif pending["op"] == OP_PHOTO_ROOTS:
- self._spawn(
- self._admin_exec_photo_roots(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_ADD:
self._spawn(
self._admin_exec_root_add(pending, transcript, sig_bytes))