diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
7 files changed, 191 insertions, 93 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index f45101f..4fc07ad 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -36,6 +36,7 @@ from pathlib import Path import uvicorn +from meshbay_common.paths import fold from meshbay_common import MNP_VERSION from meshbay_common.protocol import MNP from meshbay_node.audit import AuditStore @@ -128,6 +129,11 @@ class _WsSender: # ── Daemon ──────────────────────────────────────────────────────────────────── +def _root_shape(roots) -> set[tuple]: + """What has to match for a group's roots to count as unchanged on reload.""" + return {(r.name, str(r.path), r.writable, r.removable) for r in roots} + + class NodeDaemon: def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH): self._config = config @@ -303,7 +309,7 @@ class NodeDaemon: continue try: - roots = RootSet.build([asdict(r) for r in group_cfg.roots]) + roots = await self._build_roots(group_cfg) except RootError as e: # Configuration the operator has to fix; guessing would put # a member's file on the wrong disk or index one twice. @@ -330,7 +336,7 @@ class NodeDaemon: log.info("No GEK yet for group %s — will accept first setup", group_cfg.name) - # Read once at load, like member_upload/enabled_apps below — + # Read once at load, like enabled_apps below — # kept current in place afterwards by set_scan_settings # (ops.py), which updates both this indexer object directly # and roster.db, so a restart picks up the same values. @@ -347,6 +353,7 @@ class NodeDaemon: sk_node=keys.sk_ed25519, gek=gek, on_change=self._on_index_change, + on_root_ejected=self._eject_persister(group_cfg.id), cache=self._index_cache, reconcile_secs=scan_settings["reconcile_interval_secs"], debounce_secs=scan_settings["debounce_secs"], @@ -374,22 +381,19 @@ class NodeDaemon: "note_activity": indexer.note_activity, # Shown to the operator in Settings, and kept current in # place by set_scan_settings (ops.py) — same reasoning as - # member_upload below. + # enabled_apps below. "reconcile_interval_secs": scan_settings["reconcile_interval_secs"], "debounce_secs": scan_settings["debounce_secs"], "visibility": group_cfg.visibility, # Admission policy comes from node.toml, never from the hub: # a hub that could declare a group open would be handed its key. "join_policy": group_cfg.join_policy, - # Whether ordinary members may upload. Read once here, into - # the context, because the upload handler is synchronous and - # a database round trip per chunk would be absurd. The - # signed operation that changes it updates this dict in - # place, so the two never drift within a run. - "member_upload": await self._roster.member_upload_allowed( - group_cfg.id) if self._roster else True, - # Same reasoning: read once at load, kept current in place - # by the signed operation that changes it. + # Read once at load, kept current in place by the signed + # operation that changes it — the upload handler is + # synchronous and a database round trip per chunk would be + # absurd. (Whether a member may upload is not here any + # more: it is `writable` on the root being written to, + # 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 @@ -745,14 +749,16 @@ class NodeDaemon: if not ctx: continue try: - roots = RootSet.build([asdict(r) for r in group_cfg.roots]) + roots = await self._build_roots(group_cfg) except RootError as e: log.error("Group %r: %s — keeping the roots already loaded", group_cfg.name, e) continue - before = {(r.name, str(r.path)) for r in ctx["roots"]} - after = {(r.name, str(r.path)) for r in roots} - if before == after: + # `writable` and `removable` are in the comparison because an + # operator editing node.toml by hand and reloading is a supported + # way to change them, and a set compared on name and path alone + # reports "nothing changed" for exactly that edit. + if _root_shape(ctx["roots"]) == _root_shape(roots): continue roots.refresh_availability() indexer = next((i for i in self._indexers @@ -786,7 +792,7 @@ class NodeDaemon: continue try: - roots = RootSet.build([asdict(r) for r in group_cfg.roots]) + roots = await self._build_roots(group_cfg) except RootError as e: log.error("New group %r: %s — skipping", group_cfg.name, e) continue @@ -812,6 +818,7 @@ class NodeDaemon: sk_node=sk_ed, gek=gek, on_change=self._on_index_change, + on_root_ejected=self._eject_persister(group_cfg.id), cache=self._index_cache, reconcile_secs=scan_settings["reconcile_interval_secs"], debounce_secs=scan_settings["debounce_secs"], @@ -844,9 +851,6 @@ class NodeDaemon: "debounce_secs": scan_settings["debounce_secs"], "visibility": group_cfg.visibility, "join_policy": group_cfg.join_policy, - "member_upload": ( - await self._roster.member_upload_allowed(group_cfg.id) - if self._roster else True), "enabled_apps": ( await self._roster.enabled_apps(group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS)), @@ -1052,6 +1056,35 @@ class NodeDaemon: log.debug("Index progress pushed to %d peer(s) for group %s", pushed, group_id[:8]) + async def _build_roots(self, group_cfg) -> RootSet: + """ + Build a group's RootSet from node.toml, with the ejected state restored. + + node.toml carries configuration (`writable`, `removable`); the roster + carries the runtime answer to "is this drive ejected right now". They + are merged here, in the one place every caller goes through, because a + root that quietly comes back available across a restart is exactly the + surprise unplug that eject exists to survive. + """ + specs = [asdict(r) for r in group_cfg.roots] + if self._roster: + ejected = await self._roster.ejected_roots(group_cfg.id) + if ejected: + for spec in specs: + name = spec.get("name") or Path(spec.get("path", "")).name + if fold(name) in ejected: + spec["ejected"] = True + return RootSet.build(specs) + + 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: + if self._roster: + await self._roster.set_root_ejected( + group_id, root_name, ejected, + set_by=self._state.get("node_user_id", "")) + return persist + async def _on_index_change(self, indexer: DirectoryIndexer) -> None: """ Called when a DirectoryIndexer detects file changes — once per @@ -1716,10 +1749,10 @@ def main() -> None: help="group id (optional if only one is configured)") parser.add_argument("--writable", action="store_true", default=None, dest="writable", - help="mark root as read-write (root set/add)") + help="root accepts member uploads (root add/set)") parser.add_argument("--no-writable", action="store_false", dest="writable", - help="mark root as read-only (root set)") + help="root is read-only (root add/set, group add)") parser.add_argument("--removable", action="store_true", default=None, dest="removable", help="mark root as removable (root set/add)") @@ -2481,20 +2514,22 @@ def main() -> None: sys.exit(1) cfg = load_config(args.config or DEFAULT_CONFIG_PATH) - body = {"name": args.target, "shared_dir": args.dir} + # Writable unless the operator says otherwise: a brand-new group that + # cannot receive a single file until its owner finds a second command + # is not a working group. Every root added *later* is read-only by + # default, which is the opposite rule and the right one there. + writable = args.writable is not False + body = {"name": args.target, "shared_dir": args.dir, + "writable": writable} if args.upload_dir: - import warnings - warnings.warn( - "--upload-dir is deprecated; the main root is writable by " - "default. Use 'meshbay-node root add' for additional roots.", - DeprecationWarning, stacklevel=1) print("WARNING: --upload-dir is deprecated. The shared directory is " - "writable by default. Use 'meshbay-node root add' for " - "additional roots.") + "read-write by default; use 'meshbay-node root add " + "<path> --writable' for a second one.") body["upload_dir"] = args.upload_dir out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body) print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}") - print(f" shared_dir {out['shared_dir']} (writable)") + print(f" shared_dir {out['shared_dir']}" + f" ({'read-write' if writable else 'read-only'})") if out.get("upload_dir"): print(f" upload_dir {out['upload_dir']}") print() @@ -2512,7 +2547,7 @@ def main() -> None: group_id = _resolve_group(cfg, args.group) if sub == "list": - out = _daemon_api(cfg, f"/api/groups") + out = _daemon_api(cfg, "/api/groups") group = next((g for g in out.get("groups", []) if g["id"] == group_id), None) if not group: diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py index 2911278..f7ffdca 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py @@ -263,12 +263,17 @@ class DirectoryIndexer: cache: IndexCache | None = None, reconcile_secs: float = DEFAULT_RECONCILE_SECS, debounce_secs: float = DEFAULT_DEBOUNCE_SECS, + on_root_ejected: Callable[[str, bool], Awaitable[None]] | None = None, ): self.roots = roots self.group_id = group_id self.sk_node = sk_node self.gek = gek self.on_change = on_change + # Called with (root_name, ejected) whenever this indexer changes a + # root's ejected state by itself — the surprise-unplug safety net. + # The daemon writes it to the roster, so a restart does not undo it. + self.on_root_ejected = on_root_ejected self.reconcile_secs = reconcile_secs self.debounce_secs = debounce_secs # Current backoff delay — starts at reconcile_secs, doubles on every @@ -582,6 +587,17 @@ class DirectoryIndexer: changed = self.roots.refresh_availability() touched = False + # Drained before the loop below, because persisting the flag is what + # makes the safety net survive a restart — and a restart is exactly + # what an operator does after noticing a drive fell off. + while self.roots.auto_ejected: + name = self.roots.auto_ejected.pop(0) + if self.on_root_ejected: + try: + await self.on_root_ejected(name, True) + except Exception: + log.exception("Could not persist the auto-eject of root %r", name) + for root, available in changed: if available: log.info("Root %r is back — rescanning", root.name) diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index c3a3f9c..13a7250 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -390,7 +390,7 @@ async def list_groups(state: dict) -> dict: async def attach_group(state: dict, name: str, shared_dir: str, - upload_dir: str = "") -> dict: + upload_dir: str = "", writable: bool = True) -> dict: """ Write a new [[groups]] block into node.toml. @@ -448,7 +448,7 @@ async def attach_group(state: dict, name: str, shared_dir: str, # Forward slashes: a Windows path in a TOML basic string is a # parse error (`\U`, `\a`, ... are escapes). pathlib reads `/`. f' path = "{path.as_posix()}"\n' - f' writable = true\n') + f' writable = {"true" if writable else "false"}\n') try: with conf_path.open("a", encoding="utf-8", newline="\n") as f: f.write(block) @@ -457,6 +457,7 @@ async def attach_group(state: dict, name: str, shared_dir: str, result = {"group_id": group["id"], "name": group["name"], "shared_dir": str(path), "config": str(conf_path), + "writable": writable, "note": "restart the node to pick it up"} return result @@ -629,7 +630,7 @@ def _remove_roots_block(conf_path: Path, group_id: str, conf_path.write_text("\n".join(new_lines), encoding="utf-8", newline="\n") return - raise OpError(f"Root path not found in config", status=404) + raise OpError("Root path not found in config", status=404) async def add_root(state: dict, group_id: str, path: str, *, @@ -670,9 +671,9 @@ async def add_root(state: dict, group_id: str, path: str, *, if kind != "generic": root_block += f'\n kind = "{added.kind}"' if writable: - root_block += f'\n writable = true' + root_block += '\n writable = true' if removable: - root_block += f'\n removable = true' + root_block += '\n removable = true' _insert_roots_block(conf_path, group_id, root_block) from meshbay_node.config import RootSpec @@ -822,18 +823,17 @@ async def eject_root(state: dict, group_id: str, root_name: str) -> dict: return {"status": "already_ejected", "name": root_name, "group_id": group_id, "roots": roots.describe()} - root.ejected = True - root.available = False - - roster = _roster(state) - if roster: - await roster.set_setting( - group_id, f"root_ejected:{fold(root_name)}", "1", - set_by=state.get("node_user_id", "")) - + # The indexer stops its watchdog and freezes the entries; it holds the same + # RootSet object, but the flags are set here too so a context whose indexer + # was replaced by a retarget cannot be left disagreeing with the roster. indexer = state.get("indexers", {}).get(group_id) if indexer: indexer.eject_root(root_name) + root.ejected = True + root.available = False + + await _roster(state).set_root_ejected( + group_id, root_name, True, set_by=state.get("node_user_id", "")) log.info("Root ejected: %s from group %s", root_name, group_id[:8]) return {"status": "ejected", "name": root_name, "group_id": group_id, @@ -869,18 +869,17 @@ async def plug_root(state: dict, group_id: str, root_name: str) -> dict: f"Directory not found: {root.path}. Is the device connected?", status=409) - root.ejected = False - root.available = True - - roster = _roster(state) - if roster: - await roster.set_setting( - group_id, f"root_ejected:{fold(root_name)}", "0", - set_by=state.get("node_user_id", "")) + # Persisted before the rescan, which can take minutes on a large library: + # a crash halfway through must leave the root plugged, not ejected with + # entries half rebuilt. + await _roster(state).set_root_ejected( + group_id, root_name, False, set_by=state.get("node_user_id", "")) indexer = state.get("indexers", {}).get(group_id) if indexer: await indexer.plug_root(root_name) + root.ejected = False + root.available = root.is_live() log.info("Root plugged: %s in group %s", root_name, group_id[:8]) return {"status": "plugged", "name": root_name, "group_id": group_id, @@ -947,7 +946,7 @@ def _update_root_field(conf_path: Path, group_id: str, conf_path.write_text("\n".join(lines), encoding="utf-8", newline="\n") return - raise OpError(f"Root path not found in config", status=404) + raise OpError("Root path not found in config", status=404) # ── Files ──────────────────────────────────────────────────────────────────── @@ -1125,6 +1124,8 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: """ roster = _roster(state) ctx = _group_ctx(state, group_id) + # See the same guard in webrtc_server._do_apps_enabled: Files cannot be + # turned off, and both writers put it at the front so the two agree. if "files" not in apps: apps = ["files"] + list(apps) await roster.set_enabled_apps(group_id, apps, diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index a83b729..9d3f7cb 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -165,6 +165,13 @@ class RootSet: roots: list[Root] = field(default_factory=list) + # Roots this set ejected by itself — a removable device that went away + # without the operator clicking Eject. Drained by the indexer, which is + # the only caller holding a roster to write the state to. Without that + # the flag is lost on the next restart, and the surprise unplug looks + # like a deletion all over again on the pass after it. + auto_ejected: list[str] = field(default_factory=list) + # ── Construction ───────────────────────────────────────────────────────── @classmethod @@ -212,9 +219,14 @@ class RootSet: # Backward compat: old configs use `upload` instead of `writable` writable = bool(spec.get("writable", spec.get("upload", False))) + # `ejected` is runtime state, not configuration — it reaches here + # only from the roster, restored at startup so a drive ejected + # before a restart does not come back on its own. root = Root(name=name, path=path, kind=kind, writable=writable, removable=bool(spec.get("removable", False)), + ejected=bool(spec.get("ejected", False)), + available=not bool(spec.get("ejected", False)), direct=bool(spec.get("direct", False))) _refuse_nesting(root, roots) roots.append(root) @@ -331,8 +343,13 @@ class RootSet: changed.append((root, False)) continue live = root.is_live() - if not live and root.removable and not root.ejected: + if not live and root.removable: root.ejected = True + # Recorded for the caller to persist. A flag that only lives + # in memory would be forgotten on the next restart, and the + # rescan that followed would read an empty mount point as an + # erased library — the exact outcome eject exists to prevent. + self.auto_ejected.append(root.name) log.warning("Root %r auto-ejected (device disappeared): %s", root.name, root.path) if live != root.available: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index e2f749f..5f38acd 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -31,6 +31,8 @@ from pathlib import Path import aiosqlite +from meshbay_common.paths import fold + log = logging.getLogger(__name__) # Crockford base32 without I, L, O and U: no character pair a human can confuse @@ -543,10 +545,40 @@ class Roster: # ── Group settings ────────────────────────────────────────────────────── - # Whether members who are not the operator may upload. Default is yes: a - # group that nobody may add to is the unusual case, and an existing node - # must not change behaviour because a table was added under it. - SETTING_MEMBER_UPLOAD = "member_upload" + # Whether a root is ejected. Runtime state, one key per root, keyed by the + # *folded* name so it agrees with the case-insensitive comparison the rest + # of the root code makes. It lives here rather than in node.toml because it + # is not configuration — an operator's hand-written config file should not + # be rewritten because a USB drive was unplugged — and it has to survive a + # restart, or the rescan that follows reads an empty mount point as an + # erased library, which is the whole thing eject exists to prevent. + SETTING_ROOT_EJECTED_PREFIX = "root_ejected:" + + @classmethod + def root_ejected_key(cls, root_name: str) -> str: + return cls.SETTING_ROOT_EJECTED_PREFIX + fold(root_name) + + async def set_root_ejected(self, group_id: str, root_name: str, + ejected: bool, set_by: str = "") -> None: + await self.set_setting(group_id, self.root_ejected_key(root_name), + "1" if ejected else "0", set_by) + + async def ejected_roots(self, group_id: str) -> set[str]: + """ + The folded names of this group's ejected roots. + + Matched in Python rather than with `LIKE 'root_ejected:%'`: `_` is a + single-character wildcard there, so that pattern also matches keys this + does not own. A group has a handful of settings rows, so reading them + all costs nothing and the prefix test is then exact. + """ + prefix = self.SETTING_ROOT_EJECTED_PREFIX + async with self._db.execute( + "SELECT key, value FROM group_settings WHERE group_id = ?", + (group_id,)) as cur: + rows = await cur.fetchall() + return {r["key"][len(prefix):] for r in rows + if r["key"].startswith(prefix) and r["value"] == "1"} async def get_setting(self, group_id: str, key: str, default: str | None = None) -> str | None: @@ -567,17 +599,6 @@ class Roster: (group_id, key, value, set_by, _now())) await self._db.commit() - async def member_upload_allowed(self, group_id: str) -> bool: - """Whether an ordinary member may upload to this group.""" - value = await self.get_setting(group_id, self.SETTING_MEMBER_UPLOAD, "1") - return value != "0" - - async def set_member_upload(self, group_id: str, allowed: bool, - set_by: str = "") -> bool: - await self.set_setting(group_id, self.SETTING_MEMBER_UPLOAD, - "1" if allowed else "0", set_by) - return allowed - # Which group "applications" (Chat, Files, and whatever registers later in # apps.js) are shown to members. Unset means every app that exists — an # existing group's tabs must not disappear because a node was upgraded. @@ -609,7 +630,7 @@ class Roster: # user_id)` authorizing the operator node-wide (desktop-client-v1.md # §6.3). Unset means "the shipped default token, TMDB's own default # language" — the same "absent means the old behaviour" discipline - # member_upload/enabled_apps already follow. + # enabled_apps already follows. # # Whether TMDB is used *at all*, though, is per-group (moved off the # node-wide sentinel below, 2026-08-24): an operator running a real media 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 4d6dd34..b99affc 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -1787,7 +1787,7 @@ class WebRTCPeerSession: """ Turn a group "application" on or off for everyone, for this group. - Signed like `member_upload`: this decides what a member sees, and an + Signed like the root ops: this decides what a member sees, and an unsigned message would let any member turn a disabled one back on. """ apps = msg.get("apps") @@ -1799,8 +1799,12 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": f"Unknown app(s): {', '.join(sorted(unknown))}"}) return + # Files is not a toggle: MNP permits root exploration regardless of + # what this list says, so hiding the tab only ever misled. Added at the + # front, the same order ops.set_enabled_apps writes, so the landing-tab + # preference sees one list and not two. if "files" not in apps: - apps.append("files") + apps.insert(0, "files") if not self._has_admin_authority(): self._send({"type": "error", "detail": "No authorized key for this"}) return @@ -1884,7 +1888,7 @@ class WebRTCPeerSession: self._audit("tmdb_config", pending["subject"]) # Node-wide setting: every connected peer in every group is told, not - # just this group's peers (unlike apps_enabled/member_upload/the + # just this group's peers (unlike apps_enabled/the root ops/the # per-group tmdb_enabled below). notice = { "type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION, @@ -3795,24 +3799,34 @@ class WebRTCPeerSession: "filename": filename}) return - # The client names the target root. If absent, pick the first writable - # one (backward compat with old clients that don't send it). - target_root_name = msg.get("root") + # The client names the root it is uploading into — it is browsing one, + # and with several writable roots any other choice is a guess. It names + # a root, never a path: the destination inside it is decided below and + # is not negotiable, which is what keeps C5a closed. + # + # An unknown name is refused rather than falling back to a writable + # root, because "the file went somewhere else" is discovered weeks + # later — the same reason the old single upload root was never guessed. + # A client that names nothing is an MNP 1.0 one, and there was exactly + # one destination in its world: the first writable root. + target_root_name = str(msg.get("root") or "").strip() upload_root = None if target_root_name: - from meshbay_common.paths import fold - target_folded = fold(target_root_name) - for r in roots: - if fold(r.name) == target_folded: - upload_root = r - break + upload_root = roots.by_name(target_root_name) + if upload_root is None: + self._send({"type": "error", + "detail": f"No directory named " + f"{target_root_name!r} in this group", + "code": "no_such_root", + "filename": filename}) + return else: writable = roots.writable_roots upload_root = writable[0] if writable else None if upload_root is None: self._send({"type": "error", - "detail": "No writable directory found for uploads", + "detail": "No writable directory in this group", "code": "no_writable_root", "filename": filename}) return @@ -3827,6 +3841,7 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": f"Directory '{upload_root.name}' is " f"currently unavailable", + "code": "root_unavailable", "filename": filename}) return diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 3d24000..ef86ce3 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -176,6 +176,7 @@ def create_ui_app(state: dict) -> FastAPI: (payload.get("name") or "").strip(), (payload.get("shared_dir") or "").strip(), upload_dir=(payload.get("upload_dir") or "").strip(), + writable=bool(payload.get("writable", True)), )) reload_fn = state.get("reload_fn") if reload_fn: @@ -419,14 +420,6 @@ def create_ui_app(state: dict) -> FastAPI: "current_dir": progress.current_dir, } - # ── Upload toggle (DEPRECATED — per-root writable replaces this) ──── - - @app.put("/api/groups/{group_id}/member-upload") - async def set_member_upload(group_id: str, payload: dict): - log.warning("PUT member-upload is deprecated — use PATCH roots/{name} " - "with writable instead") - return {"deprecated": True, "message": "Use per-root writable flag"} - # ── Enabled apps (operator only, localhost) ──────────────────────────── # # Same loopback shape as member-upload: the Create Group wizard sets this |