summaryrefslogtreecommitdiffstats
path: root/docs/refactor-groups.md
blob: 3d7412b41868bfc82c721f709a613a5a8c0e4ece (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
# Groups Refactor — Per-Root Permissions & App Plugin Architecture

> Status: **Phase 1 complete and reviewed** (2026-09-06). Phase 2 and 3 not started.
>
> This is the most significant refactoring of the project. It changes how roots
> are permissioned, how group applications are configured, and how the Settings
> and Create Group pages are structured.
>
> §7b records what the review of Phase 1 found and how the plan below was wrong
> where it was wrong. Read it before starting Phase 2 — two of its entries are
> rules the later phases have to follow, not one-off fixes.

---

## 0. Summary of changes

| Area | Before | After |
|---|---|---|
| Root permissions | One root marked `upload=True`; binary `member_upload` toggle per group | Each root is **RO** (default) or **RW**; multiple RW roots allowed; fully RO group is valid |
| Upload policy | Separate section in Settings; `member_upload` signed op | **Removed.** RO/RW on the root is the mechanism. Files shows Upload only on RW roots. Chat disables attachments when its configured directory is not on a RW root |
| Root metadata | `name, path, kind, upload, direct` | `name, path, kind, writable, removable, direct` |
| Removable flag | Not tracked | Per-root boolean, set by operator. Enables the **eject/plug** button for safe device removal |
| Safe eject | Auto-detected only (`path.is_dir()`) | Operator-initiated eject button in Settings AND Files root view. `ejected` state distinct from `available`. Indexer freezes entries, no data loss |
| Files app | Can be disabled | **Always enabled**, transparently. Not shown in the app toggle list |
| App selection at group creation | Checkbox list of all apps | **Removed.** Files is enabled automatically; other apps are configured later in Settings |
| Settings layout | Monolithic: apps checkboxes, TMDB, MusicBrainz, directories, uploads — all in `group-settings.js` | **Structured:** Shared directories (top, expanded) → per-app sections (each with toggle + icon + title, collapsed, settings hidden until enabled) → Scan/Danger/Devices/Members |
| App settings code | All inlined in `group-settings.js` (1338 lines) | Each app has `ui/<APP_NAME>-app-settings.js`; loaded by discovery (presence of the file) |
| App enablement UI | One "Applications" section with checkboxes | Each app is a collapsible section with its own toggle in the title. The toggle is the enablement control |
| Folder picker | Flat `<select>` with depth-indented names | **Folder tree popup**: modal, root icons, `[+]` expand, sub-directory selection |
| Server-side app ops | Per-app functions in `ops.py` (`set_video_root`, `set_audio_root`, `set_photo_roots`, ...) | Generic `set_app_directory()` / `set_app_directories()` + app-specific wrappers where needed |
| CLI | `group add --dir --upload-dir`; `member upload` concept | `group add --dir [--writable]`; `root add/remove/set` with `--writable`/`--read-only`/`--removable` |
| MNP | 1.0 | 1.1 (additive: new fields on roots, new generic app-directory messages). 1.0 peers still work |

---

## 1. Design decisions

### 1.1 RO/RW replaces upload + member_upload

The current model has two orthogonal mechanisms: (a) one root is the upload target,
(b) `member_upload` toggles whether non-operators can upload there. The new model
collapses both into one property per root: **writable**.

- `writable = false` (default): the root is read-only for everyone, including the
  operator via the UI. Content is placed there out-of-band (filesystem, rsync, USB).
- `writable = true`: any group member may upload to this root (into the quarantine
  subdirectory, same protections as today — allowlist, no overwrite, size cap).

Multiple roots can be writable. Zero can be writable (fully read-only group). The
operator controls which roots are RW by toggling a switch in the shared directories
table.

**What this removes:**
- The `member_upload` toggle and its `OP_MEMBER_UPLOAD` signed op
- The `member_upload` / `member_upload_ack` MNP message types (deprecated, still
  parsed for backward compat)
- The Uploads section in Settings
- The concept of "the upload root" (singular)

**What this preserves:**
- The quarantine directory, filename allowlist, no-overwrite check, size cap
- The `_do_file_upload` handler in `webrtc_server.py` — now checks `writable` on
  the target root instead of checking `upload` + `member_upload`
- The operator's ability to create a read-only group (set all roots RO)

### 1.2 Files is always enabled

`files` is removed from the toggleable app list. It is always present in
`enabled_apps` and cannot be disabled. The current ability to hide it was misleading:
MNP still permits root exploration regardless. The tab bar always shows Files.

`apps.js` keeps `files` in `APPS` but marks it `alwaysEnabled: true`. The Settings
page skips it when rendering app toggle sections.

### 1.3 Per-app settings files

Each app that has configurable settings exports a settings component from
`ui/<APP_NAME>-app-settings.js`. The file is optional — an app with no settings
(like Files today) has no settings file and gets only a toggle.

The `APPS` registry in `apps.js` gains an optional `Settings` field per entry,
imported from the corresponding settings file. The group-settings page iterates
`APPS`, skips `files`, and renders a collapsible section for each, with:

- The app's monochrome icon + localized title in the section header
- A toggle switch in the header (disabled by default)
- The app's `Settings` component below, **hidden until the toggle is on**
- A Save button per app section (some saves trigger caching — TMDB, MusicBrainz)

**Discovery mechanism:** In the browser context, "file discovery" is registration in
`apps.js`. Adding a new app means: write `<APP>-app.js` + `<APP>-app-settings.js`,
add one entry to `APPS` in `apps.js`, add the key to `ALLOWED_APPS` on the node.
Server-side enforcement via `ALLOWED_APPS` prevents client-side hacks from enabling
an unrecognized app.

### 1.4 Folder tree widget

A reusable modal popup (`FolderTreePicker`) that:

- Appears centered on screen, semi-transparent backdrop
- Lists root directories at the top level, with the same folder icons as Files
- Each root can be expanded via `[+]` / collapsed via `[-]`, explorer-tree style
- Sub-directories load from the existing `nodeDirs` data (already available from the
  file index, no new endpoint needed)
- Supports **single-select** mode (Chat) and **multi-select** mode (Videos, Music,
  Photos)
- Shows the root's RO/RW badge next to each root name
- For apps that require RW (Chat): RO roots and their children are greyed out /
  unselectable, with a tooltip explaining why
- OK / Cancel buttons at the bottom
- Returns the selected path(s) relative to the root (e.g., `Movies/Action`)

The widget replaces the current flat `<select>` dropdowns in all app settings. It is
also usable in other UI contexts (the video player's folder navigation already does
something similar ad-hoc).

### 1.5 Shared directories table

A reusable component (`SharedDirectoriesTable`) used in both the group Settings page
and the Create Group wizard (developed once, shared). Features:

- Borderless table, one row per root
- Columns: **Name** (with folder icon), **Path** (truncated with tooltip on hover),
  **RW toggle** (switch, default off), **Removable toggle** (checkbox),
  **Eject/Plug button** (visible only when removable is checked — see §1.5b),
  **Delete button** (trash icon, with confirmation)
- Ejected roots show a distinct visual state: greyed-out row, eject icon replaced by
  a plug icon
- The first root in the Create Group wizard defaults to RW
- A concise explanatory sentence at the top: "At least one directory is required.
  Read-write directories accept uploads from group members."
- Add button: opens the native folder picker (Electron) or a path input (web, admin
  only)
- Cannot delete the last root (refused with explanation)
- Each change is a signed operator op (`ROOT_ADD`, `ROOT_REMOVE`, or new
  `ROOT_UPDATE` for toggling writable/removable on an existing root)

### 1.5b Safe eject for removable devices

**Problem.** An operator stores data on a USB drive. Unplugging it without warning
triggers the watchdog — file deletions propagate as though the operator erased an
entire library. The existing `refresh_availability()` auto-detects this and freezes
entries (good), but there is no way to eject cleanly before unplugging, and no way
to re-plug without a full rescan.

**The `ejected` state.** A root has two independent runtime states:

- `ejected` (bool, default `false`): operator-controlled, persisted in `roster.db`.
  Set by clicking the eject button; cleared by clicking plug.
- `available` (bool, runtime): computed as `not ejected and is_live()`. This is
  what clients and the indexer see.

The distinction matters: when the operator clicks "eject" but hasn't physically
unplugged yet, `is_live()` returns `true` but `available` is `false` because
`ejected` is `true`. Without this, `refresh_availability()` would immediately
flip it back to available.

**Eject flow:**

1. Operator clicks the eject button (⏏) on a removable root
2. Confirmation dialog: "Eject *Movies*? Files from this directory will be
   temporarily hidden to all members. You can safely unplug the device."
3. On confirm: `PUT /api/groups/{gid}/roots/{name}/eject` → `ops.eject_root()`
4. `ops.eject_root()`: sets `ejected = true` in `roster.db`, marks root
   `available = false`, stops the watchdog observer for that root
5. The indexer **freezes** all entries from that root (existing behavior — no
   deletions, no index updates, cached data preserved)
6. `index_sync` update propagates to connected peers: the root's `available` is
   now `false`
7. All apps filter out entries from unavailable roots (Files already does this
   partially — needs to be complete across Videos, Music, Photos)
8. The operator can now safely unplug the device

**Plug flow:**

1. Operator plugs the device back in and clicks the plug button (🔌)
2. `PUT /api/groups/{gid}/roots/{name}/plug` → `ops.plug_root()`
3. `ops.plug_root()`: checks `is_live()` first — if the path is not accessible,
   refuses with "Directory not found. Is the device connected?"
4. On success: sets `ejected = false`, marks root `available = true`, restarts
   the watchdog observer
5. The indexer **rescans the root** — its frozen entries are dropped and the
   directory is read again. (The plan said "a reconciliation, not a full
   rescan"; it is a rescan, deliberately. It is the same path a root coming
   back from `refresh_availability` already took, and a device people carry
   around can come back arbitrarily different — the hash cache means unchanged
   files are not re-read, which is where the cost would have been.)
6. `index_sync` update propagates — entries reappear in all apps

**The flag is persisted, and restored at startup.** `ejected` lives in
`roster.db` (`root_ejected:<folded name>`), not in `node.toml`: it is runtime
state, and an operator's hand-written config must not be rewritten because a USB
drive was unplugged. It has to survive a restart — a restart is exactly what an
operator does after noticing a drive fell off, and a flag that only lived in
memory would let the scan that follows read the empty mount point as an erased
library. `daemon._build_roots()` merges the two sources; it is the only place
that builds a `RootSet` for a group.

**Auto-detection safety net.** If a `removable` root's path suddenly disappears
(operator unplugged without clicking eject):

- `refresh_availability()` detects `is_live() = false`
- Because `removable = true`, it sets `ejected = true` automatically (as if the
  operator had clicked eject), and reports it through the indexer's
  `on_root_ejected` callback so the daemon writes it to `roster.db` — an
  auto-eject that only existed in memory would be undone by the next restart
- Entries freeze, no deletions propagate
- The root stays in "ejected" state until the operator explicitly plugs it back

For non-removable roots, the existing behavior is unchanged: `available` flips
based on `is_live()`, entries freeze when unavailable, rescan when available again.

**Eject button in Files app.** In addition to the Settings table, an eject button
appears in the Files app root-level view, next to each removable root's name. This
provides quick access without navigating to Settings. Same confirmation dialog,
same API call. Ejected roots show as greyed-out with a plug icon to re-enable.

**What is NOT deleted on eject:**

- Index entries (frozen, not removed)
- TMDB / MusicBrainz cached metadata
- Video thumbnails in `media_cache`
- Chat message history referencing files on that root
- App directory configurations pointing to that root (but flagged as temporarily
  invalid — the app shows a warning, not an error)

**MNP message:** `ROOT_EJECT` / `ROOT_EJECT_ACK` and `ROOT_PLUG` / `ROOT_PLUG_ACK`
— signed operator ops, same pattern as `ROOT_UPDATE`. Broadcast to all connected
peers so they see the availability change immediately without waiting for the next
`index_sync`.

### 1.6 Server-side normalization (ops.py)

Two generic functions replace the per-app specific ones:

```python
def set_app_directory(state, group_id, app_key, path, *, require_writable=False):
    """Set a single directory for an app. Validates path is within a named root.
    If require_writable, refuses paths under RO roots."""

def set_app_directories(state, group_id, app_key, paths, *, require_writable=False):
    """Set multiple directories for an app. Same validation."""
```

Existing functions (`set_video_root`, `set_audio_root`, `set_photo_roots`) become
thin wrappers calling the generic versions, preserving the current MNP message types
and roster keys for backward compat. New apps use the generic functions directly.

### 1.7 Chat settings additions

- **Directory picker** (single, RW-only): selects the directory for chat file
  attachments. If no RW root exists, the picker shows an explanation and the
  attachment button is disabled in the chat UI. If the selected directory's root is
  later set to RO, the setting is flagged as invalid and attachments are disabled
  until corrected.
- **Link preview toggle** (new, server-side): the operator can disable link previews
  for the group. Stored in `roster.db` as `chat_link_preview` (default: enabled).
  The node's `linkpreview.py` checks this setting before unfurling. The toggle is a
  `ToggleSwitch` in the Chat settings section.

### 1.8 Videos/Music/Photos settings changes

**Videos:**
- Directory picker changes from single to **multi-directory** (via folder tree widget)
- TMDB settings section moved here from the monolithic settings
- TMDB API key field: no longer says "optional" or mentions the default key.
  Instead, a prompt to sign up on TMDB with a direct link to generate a key
- Per-app Save button triggers TMDB cache sweep

**Music:**
- Directory picker changes from single to **multi-directory**
- MusicBrainz settings section moved here
- Per-app Save button triggers MusicBrainz cache sweep

**Photos:**
- Multi-directory picker (already multi, just moves to the folder tree widget)
- No third-party service settings

### 1.9 Handshake ack changes

The `handshake_ack` payload gains per-root metadata:

```python
# Current
"roots": [{"name": "Movies", "path": "/mnt/movies", ...}]
"member_upload": True

# New
"roots": [{"name": "Movies", "path": "/mnt/movies", "writable": False, "removable": True, ...}]
# member_upload removed (deprecated, still parsed by old clients)
```

For backward compatibility with MNP 1.0 peers:
- A 1.0 client that does not see `writable` on roots falls back to the old model
  (root with `upload=True` is writable, `member_upload` from the ack controls access)
- A 1.1 node continues to send `member_upload` as a computed value: `True` if any
  root is writable, `False` otherwise — so old clients behave sensibly
- `member_upload` is no longer writable via MNP ops; the node computes it from roots

### 1.10 CLI changes

As built. The group is a `--group` option rather than a positional, matching
every other verb in this CLI, and the negative flags are spelled `--no-writable`
/ `--no-removable` rather than `--read-only`, so each pair reads as one setting.

```
# Group creation (first root defaults to RW)
meshbay-node group add <name> --dir <path>                    # first root, RW
meshbay-node group add <name> --dir <path> --no-writable      # first root, RO

# Root management (--group is optional with one group configured)
meshbay-node root list [--group <name>]
meshbay-node root add <path> [--name <name>] [--writable] [--removable]
meshbay-node root remove <name> [--yes]
meshbay-node root set <name> --writable | --no-writable
meshbay-node root set <name> --removable | --no-removable
meshbay-node root eject <name>                                # safe eject
meshbay-node root plug <name>                                 # re-plug

# Deprecated (accepted with a warning)
--upload-dir   → "use --writable on the target root instead"
member upload  → removed; use 'root set --no-writable' / '--writable'
```

### 1.11 HelloWorld proof-of-concept

A minimal app that validates the plugin architecture end to end:

- `static/helloworld-app.js`: renders a greeting and lists files in its configured
  directory
- `static/helloworld-app-settings.js`: single-directory picker (via folder tree
  widget), no other settings
- Entry in `apps.js` with `key: "helloworld"`, icon, label, Component, Settings
- `ALLOWED_APPS` extended on the node
- No dedicated `helloworld.py` — uses the generic `set_app_directory()` function,
  which is the whole point

The HelloWorld app is **not shipped in production**. It lives in the tree as a
reference implementation and can be excluded from the build. Its value is proving
that the plugin mechanism works: zero changes to `group-settings.js`,
`group-page.js`, `webrtc_server.py` or `ops.py` to add it.

### 1.12 Migration (existing nodes)

A script in `QE/migration/` (not versioned) handles Fedora and Ubuntu nodes:

**node.toml:**
- `upload = true` → `writable = true`
- `upload = false` (or absent) → `writable = false`
- Add `removable = false` to all roots that lack it
- Remove `upload_dir` from `[[groups]]` blocks (if present)

**roster.db:**
- If `member_upload = "off"` for a group: set all that group's roots to
  `writable = false` (the intent was "no uploads")
- Rename `video_root` → `video_directories` (wrap single value in a list)
- Rename `audio_root` → `audio_directories` (same)
- `photo_roots` → `photo_directories` (rename only)
- Remove `member_upload` rows
- Add `chat_link_preview = "true"` default for groups with chat enabled
- Add `chat_directory` for groups that had an upload root (default: the upload
  root's path)

**Protocol version:**
- MNP version file bumped to 1.1

**The script is idempotent** — running it twice is safe.

---

## 2. Phase 1 — Root RO/RW model + Shared Directories UI

**Goal:** Change the data model from `upload` to `writable`/`removable`, build the
shared directories table, restructure the top of Settings and the Create Group
wizard. Remove the Uploads section. Files always enabled.

**Testable after this phase:** Create a group with RO/RW roots, toggle RW in
Settings, add/remove roots in the new table, see the Upload button appear/disappear
in Files based on the current root's writable flag, eject a removable root and verify
files disappear from all apps without data loss, plug it back and verify files
reappear, CLI works with new syntax including `root eject/plug`.

### 2.1 Backend changes

| File | Change |
|---|---|
| `config.py` | `RootSpec`: add `writable: bool = False`, `removable: bool = False`. Remove `upload` field. `__post_init__` migration: `upload=True` → `writable=True`. Parse new fields from node.toml |
| `roots.py` | `Root` dataclass: add `writable`, `removable`, `ejected`. Remove `upload`. `available` becomes a computed property: `not self.ejected and self.is_live()`. `_settle_upload_root()` removed. `RootSet.build()`: validate at least one root exists (no RW minimum). `refresh_availability()`: when a `removable` root's path disappears, auto-set `ejected=True` (safety net). Collision checks unchanged |
| `ops.py` | `add_root()`: accept `writable`, `removable` params. `remove_root()`: refuse removing last root (unchanged). `attach_group()`: first root defaults to `writable=True`. Remove `set_member_upload()`. New: `update_root()` for toggling writable/removable on an existing root (signed op `OP_ROOT_UPDATE`). New: `eject_root()` — sets `ejected=True`, stops watchdog for that root. New: `plug_root()` — checks `is_live()`, sets `ejected=False`, triggers reconciliation |
| `roster.py` | No schema change (generic key/value). Remove `member_upload` handling from `_apply_group_settings()` |
| `ui/app.py` | `POST /api/groups/{gid}/roots`: accept `writable`, `removable`. New: `PATCH /api/groups/{gid}/roots/{name}` → `ops.update_root()`. New: `PUT /api/groups/{gid}/roots/{name}/eject` → `ops.eject_root()`. New: `PUT /api/groups/{gid}/roots/{name}/plug` → `ops.plug_root()`. Remove `PUT /api/groups/{gid}/member-upload` |
| `daemon.py` (CLI) | New `root` subcommand: `add`, `remove`, `set`, `list`. `group add --dir` defaults to `writable=True`. Deprecate `--upload-dir` with warning. Remove `member upload` command |
| `webrtc_server.py` | Handshake ack: add `writable`/`removable`/`ejected` per root. Compute `member_upload` for backward compat. `_do_file_upload`: check `root.writable` instead of `root.upload` + `member_upload_allowed`. Handle `ROOT_UPDATE`, `ROOT_EJECT`, `ROOT_PLUG` MNP messages. Broadcast root availability changes to all connected peers. `ALLOWED_APPS`: add `"files"` to always-enabled set |
| `protocol.py` | New message types: `ROOT_UPDATE` / `ROOT_UPDATE_ACK`, `ROOT_EJECT` / `ROOT_EJECT_ACK`, `ROOT_PLUG` / `ROOT_PLUG_ACK`. `ROOT_ADD` gains `writable`, `removable` fields |
| `indexer.py` | `eject_root()`: stop the watchdog observer for that root, do NOT touch entries. `plug_root()`: restart observer, trigger reconciliation pass. `refresh_availability()`: auto-eject removable roots whose path disappears (set `ejected=True` instead of just flipping `available`) |
| `handshake.py` | MNP version → 1.1 (minor, additive) |

### 2.2 Frontend changes

| File | Change |
|---|---|
| `group-settings.js` | New `SharedDirectoriesTable` component (reusable). Includes eject/plug button per removable root. Move to top of settings (after Invite/Pair). Remove the Uploads toggle section. Remove the old Directories section (root management part — app root pickers stay for now). Calls loopback API for add/remove/update/eject/plug root |
| `create-group-page.js` | Replace app checkboxes with nothing (Files auto-enabled). Replace directory section with `SharedDirectoriesTable` (same component). First root defaults `writable=true`. Step 2: remove `set enabled_apps` call (Files is automatic) |
| `files-app.js` | Upload button visibility: check `currentRoot.writable && currentRoot.available`. Upload target: the root currently being browsed. Hide upload affordances on RO roots. Eject/plug button next to each removable root name in root-level view. Ejected roots greyed out with plug icon. Entries from unavailable roots filtered out of all views |
| `chat-app.js` | Attachment button: disabled if no RW root exists or if the chat directory's root is RO or unavailable. Tooltip explaining why |
| `group-page.js` | `nodeRoots` state: include `writable`/`removable`/`ejected` from handshake ack. Handle `ROOT_EJECT_ACK`/`ROOT_PLUG_ACK` broadcasts to update root state live. Remove `memberUpload` state. Remove `onUploadPolicy` callback |
| `video-app.js` | Filter entries: exclude files from unavailable roots |
| `music-app.js` | Filter entries: exclude files from unavailable roots |
| `photos-app.js` | Filter entries: exclude files from unavailable roots |
| `apps.js` | Add `alwaysEnabled: true` to `files` entry |
| `transport.js` | Parse new root fields from handshake ack |

### 2.3 Localization

New keys in `locales/*.js`:
- `sharedDirectories`, `sharedDirectoriesHint` ("At least one directory is
  required...")
- `readOnly`, `readWrite`, `removableDevice`
- `uploadNotAvailableRO` (tooltip: "This directory is read-only")
- `ejectRoot`, `ejectRootConfirm` ("Eject *{name}*? Files from this directory
  will be temporarily hidden...")
- `plugRoot`, `plugRootFailed` ("Directory not found. Is the device connected?")
- `rootEjected` (status label shown on ejected roots)
- Deprecation: `uploadToggle*` keys can be removed

---

## 3. Phase 2 — App Settings Plugin Architecture + Folder Tree Widget

**Goal:** Split app settings into per-app files, build the folder tree widget,
restructure Settings with per-app collapsible sections. Normalize server-side
ops.

**Testable after this phase:** Each app has its own settings section with toggle.
Folder tree popup works for directory selection. TMDB/MusicBrainz settings are in
their app sections. Chat has link preview toggle. Videos/Music use multi-directory.

### 3.1 Folder tree widget

| File | Change |
|---|---|
| `static/folder-tree.js` (new) | `FolderTreePicker` component. Props: `roots` (with writable/removable), `dirs` (flat list from index), `mode` ("single"/"multi"), `requireWritable` (bool), `selected` (current selection), `onSelect` callback. Renders a modal with tree-view of directories. Builds tree structure from flat `nodeDirs` paths |

**Behavior:**
- Modal overlay with semi-transparent backdrop, centered panel
- Root level: each root with folder icon, name, RO/RW badge, removable badge
- `[+]` / `[-]` toggle to expand/collapse children
- Directories sorted alphabetically at each level
- Single mode: clicking a directory selects it (highlight), deselects previous
- Multi mode: clicking a directory toggles its selection (checkbox visual)
- `requireWritable=true`: RO roots and all their children are greyed out and
  unclickable, with a brief explanation at the top of the modal
- Selected path shown at the bottom of the modal
- OK (disabled if nothing valid selected) and Cancel buttons
- Escape key closes

### 3.2 Per-app settings files

| File | Content |
|---|---|
| `static/chat-app-settings.js` (new) | `ChatSettings` component. Single-directory picker (folder tree, `requireWritable=true`). Link preview toggle (`ToggleSwitch`). Save button. Validates: selected directory must be on a RW root |
| `static/video-app-settings.js` (new) | `VideoSettings` component. Multi-directory picker (folder tree). TMDB section: toggle + API key + language (moved from `group-settings.js`). TMDB key prompt: "Sign up on TMDB to generate your API key" with link, no "optional" wording. Save triggers TMDB sweep |
| `static/music-app-settings.js` (new) | `MusicSettings` component. Multi-directory picker. MusicBrainz section: toggle (moved from `group-settings.js`). Save triggers MusicBrainz sweep |
| `static/photos-app-settings.js` (new) | `PhotoSettings` component. Multi-directory picker. No third-party settings. Save button |
| `static/apps.js` | Each `APPS` entry gains `Settings` field imported from the corresponding settings file. `files` has no `Settings` (no per-app config). `files` has `alwaysEnabled: true` (from Phase 1) |

### 3.3 Settings page restructure

| File | Change |
|---|---|
| `group-settings.js` | Remove all inlined app settings (TMDB section, MusicBrainz section, app root pickers). Remove the Applications checkbox section. New rendering loop: for each app in `APPS` where `!app.alwaysEnabled && app.Settings`, render a `CollapsibleSection` with: icon + title in header, toggle switch in header, `app.Settings` component inside (visible only when enabled). Section collapsed by default. App toggle triggers signed `OP_APPS_ENABLED` op |

**Final settings layout:**

```
1. Invite form                    (if admin & invite-only)
2. Pair operator                  (if nodeAdmin & not paired)
── App configuration ──
3. Shared directories             (expanded, SharedDirectoriesTable)
4. 💬 Chat                        [toggle] (collapsed)
     └─ Directory picker, Link preview toggle, Save
5. 🎬 Videos                      [toggle] (collapsed)
     └─ Directory picker (multi), TMDB settings, Save
6. 🎵 Music                       [toggle] (collapsed)
     └─ Directory picker (multi), MusicBrainz settings, Save
7. 📷 Photos                      [toggle] (collapsed)
     └─ Directory picker (multi), Save
8. [Future apps discovered from APPS registry]
── Node tuning ──
9. Scan tuning                    (reconcile, debounce)
── Danger zone ──
10. Leave / Delete group
── Identity ──
11. My devices
12. Members table
```

### 3.4 Server-side normalization

| File | Change |
|---|---|
| `ops.py` | New generic: `set_app_directory(state, group_id, app_key, path, require_writable=False)` and `set_app_directories(state, group_id, app_key, paths, require_writable=False)`. Both validate path(s) within named roots and check writable if required. Existing `set_video_root` → wrapper calling `set_app_directories("video", ...)`. Same for audio, photo. New: `set_chat_directory(state, group_id, path)` → `set_app_directory("chat", path, require_writable=True)`. New: `set_chat_link_preview(state, group_id, enabled)` |
| `webrtc_server.py` | Handle new MNP messages: `CHAT_DIRECTORY` / `ACK`, `CHAT_LINK_PREVIEW` / `ACK`. Refactor `VIDEO_ROOT` handler to use generic. Handshake ack: add `chat_directory`, `chat_link_preview` |
| `protocol.py` | New message types: `CHAT_DIRECTORY`, `CHAT_LINK_PREVIEW` (and acks) |
| `ui/app.py` | New endpoints: `PUT /api/groups/{gid}/chat-directory`, `PUT /api/groups/{gid}/chat-link-preview`. Generic: `PUT /api/groups/{gid}/app-directories/{app_key}` |
| `linkpreview.py` | Check `chat_link_preview` setting before unfurling |

### 3.5 Handshake ack additions

```python
"chat_directory": "/Shared/uploads",
"chat_link_preview": True,
"video_directories": ["/Movies", "/Series"],  # replaces video_root (single)
"audio_directories": ["/Music"],              # replaces audio_root (single)
"photo_directories": ["/Photos", "/Camera"],  # replaces photo_roots
```

Old field names (`video_root`, `audio_root`, `photo_roots`) still sent for backward
compat with MNP 1.0 clients. New clients read the `*_directories` form.

---

## 4. Phase 3 — HelloWorld, CLI polish & Migration

**Goal:** Prove the plugin architecture with a HelloWorld app, finalize CLI changes,
write and test the migration script.

### 4.1 HelloWorld app

| File | Content |
|---|---|
| `static/helloworld-app.js` (new) | Minimal component: renders "Hello, World!" heading + lists files from its configured directory. Uses `entries` from `commonProps`, filtered by the configured path |
| `static/helloworld-app-settings.js` (new) | `HelloWorldSettings` component: single-directory picker via `FolderTreePicker`, Save button. Uses `set_app_directory("helloworld", path)` on the generic endpoint |
| `apps.js` | New entry: `{ key: "helloworld", icon: "👋", labelKey: "helloWorld", Component: HelloWorldApp, Settings: HelloWorldSettings }` |
| `webrtc_server.py` | Add `"helloworld"` to `ALLOWED_APPS` |

**Validation:** enabling HelloWorld in Settings, picking a directory, saving, and
seeing the file list in the HelloWorld tab — with **zero changes** to
`group-settings.js`, `group-page.js`, or `ops.py`. If this works, any future app
can be added the same way.

### 4.2 CLI final polish

- `root` subcommand fully tested: `add`, `remove`, `set`, `list`
- `group add --upload-dir` prints deprecation warning and maps to `--dir <path>
  --writable`
- `member upload` command removed (prints migration guidance)
- Help text updated to reflect RO/RW model
- `test_cli_dispatch.py` updated for all new verbs

### 4.3 Migration script

`QE/migration/migrate_groups_v2.sh` (or `.py`) — not versioned, for Fedora/Ubuntu
nodes only.

**node.toml transformations:**
```
upload = true        →  writable = true
upload = false       →  writable = false
(no upload field)    →  writable = false
(add removable = false to every root that lacks it)
(remove upload_dir lines from [[groups]] blocks)
```

**roster.db transformations:**
```sql
-- Convert member_upload=off to all-RO roots (handled via ops on restart)
-- Rename app directory keys
UPDATE group_settings SET key = 'video_directories' WHERE key = 'video_root';
UPDATE group_settings SET key = 'audio_directories' WHERE key = 'audio_root';
UPDATE group_settings SET key = 'photo_directories' WHERE key = 'photo_roots';
-- Wrap single values in JSON arrays for video/audio
-- Add chat defaults
INSERT INTO group_settings (group_id, key, value)
  SELECT group_id, 'chat_link_preview', 'true'
  FROM group_settings WHERE key = 'enabled_apps' AND value LIKE '%chat%';
-- Remove member_upload rows
DELETE FROM group_settings WHERE key = 'member_upload';
```

**Idempotency:** every transformation is guarded (`IF NOT EXISTS`, check before
rename, etc.). Safe to run twice.

**Rollback:** the script backs up `node.toml` and `roster.db` before any change.

### 4.4 Windows notes

No migration script needed for Windows (manual setup). Functional non-regression
testing only:
- Drive letter roots (`D:\Movies`, `E:\Music`) work with RO/RW
- Removable flag on USB drives
- Folder tree widget handles backslash paths
- Create Group wizard with Windows paths

---

## 5. Risk assessment

| Risk | Mitigation |
|---|---|
| Upload regression | Phase 1 is self-contained: test uploads on RW roots, verify refused on RO roots, before touching app settings |
| Eject data loss | Eject freezes entries (existing `freeze-not-empty` path). Ejected roots are never rescanned. Auto-eject safety net for surprise unplugs on removable roots. Explicit reconciliation (not full rescan) on plug |
| Indexer race on eject | Watchdog observer is stopped synchronously before `ejected=true` is set. No window where the watchdog sees a missing path and processes deletions |
| MNP backward compat | Computed `member_upload` in ack for 1.0 clients. New fields additive. Old field names kept alongside new ones |
| Monolithic `group-settings.js` diff | Phase 2 extracts code into new files; the old code is deleted, not refactored. Clear before/after |
| Folder tree perf with large indexes | `nodeDirs` is already computed. Tree construction is O(n) on directory count, not file count. Lazy child rendering on expand |
| Windows path handling | Existing NFC normalization and path handling unchanged. New fields (`writable`, `removable`) are path-independent booleans. Drive letters work as root paths |
| Migration data loss | Script backs up before changes. Idempotent. Tested on a staging node before production |

---

## 6. Files touched (by phase)

### Phase 1
```
packages/meshbay-node/src/meshbay_node/config.py
packages/meshbay-node/src/meshbay_node/roots.py
packages/meshbay-node/src/meshbay_node/ops.py
packages/meshbay-node/src/meshbay_node/roster.py
packages/meshbay-node/src/meshbay_node/ui/app.py
packages/meshbay-node/src/meshbay_node/daemon.py
packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
packages/meshbay-common/src/meshbay_common/protocol.py
packages/meshbay-common/src/meshbay_common/handshake.py
packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
packages/meshbay-hub/src/meshbay_hub/static/group-page.js
packages/meshbay-hub/src/meshbay_hub/static/files-app.js
packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
packages/meshbay-hub/src/meshbay_hub/static/apps.js
packages/meshbay-hub/src/meshbay_hub/static/transport.js
packages/meshbay-node/src/meshbay_node/indexer/indexer.py
packages/meshbay-hub/src/meshbay_hub/static/video-app.js
packages/meshbay-hub/src/meshbay_hub/static/music-app.js
packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
packages/meshbay-hub/src/meshbay_hub/static/locales/*.js
```

### Phase 2
```
packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js          (new)
packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js    (new)
packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js   (new)
packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js   (new)
packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js  (new)
packages/meshbay-hub/src/meshbay_hub/static/group-settings.js       (extract)
packages/meshbay-hub/src/meshbay_hub/static/apps.js
packages/meshbay-node/src/meshbay_node/ops.py
packages/meshbay-node/src/meshbay_node/ui/app.py
packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
packages/meshbay-node/src/meshbay_node/linkpreview.py
packages/meshbay-common/src/meshbay_common/protocol.py
packages/meshbay-hub/src/meshbay_hub/static/locales/*.js
```

### Phase 3
```
packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js          (new)
packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js (new)
packages/meshbay-hub/src/meshbay_hub/static/apps.js
packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
packages/meshbay-node/src/meshbay_node/daemon.py
tests/test_cli_dispatch.py
QE/migration/migrate_groups_v2.py                                      (new, not versioned)
```

---

## 7. Estimated effort

| Phase | Scope | Approx. size |
|---|---|---|
| Phase 1 | Data model + shared dirs UI + eject/plug + CLI + remove uploads | ~1800 lines changed/added |
| Phase 2 | Folder tree + 4 app-settings files + settings restructure + server normalization | ~1800 lines changed/added |
| Phase 3 | HelloWorld + CLI polish + migration script | ~500 lines |

Each phase is one focused Claude session. Test between phases.

---

## 7b. Phase 1 review (2026-09-06)

What the plan above got wrong, and what was actually built. The first three
entries are **rules for phases 2 and 3**, not one-off fixes: each describes a
shape the same code can take again.

### The rules

**An operator is not sitting at their node.** The shared directories table read
its roots exclusively from the loopback API (`platform.node.available`), which
resolves to "not available" in a browser. So the section rendered for nobody on
the web — while the Uploads controls it replaced *had* worked there — and the
`transport.updateRoot` / `ejectRoot` / `plugRoot` methods written next to it
were unreachable. §2.2 of the plan said "calls loopback API", and that was the
mistake: MNP is the path that must exist, and loopback is the fallback for a
local node with no live connection. Every operator-facing control phase 2 adds
(the folder tree, four app settings panes, the link-preview toggle) needs the
MNP route first. Pinned by `test_upload_controls_hidden.py`.

**A reply that carries state nobody could have predicted has to be handed on.**
`transport.js` resolves an admin `*_ack` against the pending request and
returns, deliberately: every caller already updates local state from the value
it chose. The root acks are not like that — they carry the node's whole roots
table, including things only it knows (availability, the name it settled on,
the eject a failed plug left in place). Returning left the operator who clicked
Eject as the single client that never saw it happen, while every *other* peer
got the broadcast. Any phase-2 op returning computed state has the same shape.

**A control that writes needs to name where.** The node was given a `root`
field on `file_upload` and no client ever sent it, so every upload went to
`writable_roots[0]` while the Files toolbar offered the button based on the root
being browsed. With two writable roots, uploading from one wrote into the other.
This is the failure `_settle_upload_root`'s deleted docstring existed to
prevent, reintroduced by removing it. Chat's attachments have the same problem
one level up and get an explicit `attachRoot` until §1.7 gives them a
configured directory.

### The rest

- **`ejected` was written to `roster.db` and never read back**, and the
  auto-eject path did not persist at all. Both fixed; see §1.5b.
- **`PUT /api/groups/{gid}/member-upload` became a stub returning `200
  {"deprecated": true}`.** A route that answers OK and changes nothing is
  indistinguishable from a working one to whoever calls it. Removed.
- **The wizard ignored the first root's RW switch** — `ops.attach_group` always
  wrote `writable = true`. It takes the flag now.
- **`refresh_availability` was the only reader of a root's config.** The reload
  path compared roots on `(name, path)`, so an operator editing `writable` in
  `node.toml` and reloading saw nothing happen. The comparison includes the
  flags.
- **The table had no Path column** (§1.5 asked for one). Two libraries whose
  folders share a basename are indistinguishable without it, and the basename is
  the identity — so it is the one thing that has to be visible.
- **Phase 1 shipped no tests.** 29 of the suite's failures were its own. The
  gap that mattered was not the broken helpers but that eject, plug, per-root
  upload refusal and the `node.toml` rewrite had no coverage at all:
  `test_root_eject.py`, `test_root_writable_policy.py` and the new cases in
  `test_ops.py` / `test_node_status.py` / `test_security_regressions.py` are
  that. `test_member_upload_policy.py` is gone — it tested a removed feature.
- **`chat-app.js` was in §2.2's file list and was never touched.**
- **Eight of the ten locales were missing the new keys.** `test_locales.py`
  holds them to `en.js`, so this was a failing test rather than a silent gap —
  but it is worth noting that adding a key means adding it ten times.

### Still open, deliberately

- **The operator can no longer have a directory only they may write to.** RW is
  open to every member; RO refuses everyone including the operator. This
  reverses draft-v6's structural decision 9, which is annotated there. It is a
  real capability removed, and if it turns out to be wanted the answer is a
  third state on the root, not the old group-wide switch.
- `test_ops.py::test_a_backslash_path_written_into_node_toml_stays_parseable`
  fails on any non-Windows machine and always has — it builds a
  `PurePosixPath` from a Windows path. Unrelated to this refactor, left alone.