diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:15:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:15:02 +0200 |
| commit | e9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch) | |
| tree | b5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-hub/src/meshbay_hub | |
| parent | 50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff) | |
| download | meshbay-e9d5e979fdab9a1cc3c729d602e6f27207b9480c.tar.gz | |
feat(node): several named roots per group, and one implementation per operation
Stage A — a group's content is a set of named roots
---------------------------------------------------
`shared_dir` becomes a list of {name, path, kind}. The name is the directory's
basename, derived once at add time and *stored*: recomputing it would
re-identify a whole library the day someone renames a folder on disk. Duplicate
names are refused case-insensitively and no root may contain another — both
compared with NFC folding, because most of these directories live on exFAT or
NTFS where `Films` and `films` are one directory.
Every index path carries its root name, in a one-root group as much as in a
five-root one. One path shape has to be got right once; two have to be kept
right for ever.
**A root that goes away freezes; it never empties.** Unmounting a volume makes
watchdog report every file under it as deleted, or presents an empty directory
to the next scan. Acting on either propagates deletions for a whole library to
every member, as though the owner had erased it. So a deletion is acted on only
once its root is confirmed readable, and availability is tracked per root — one
unplugged drive leaves the others serving. 12 tests, verified to fail against an
indexer without the check.
Events are not trusted to be complete either: ReadDirectoryChangesW drops them
under load and inotify on a FUSE mount misses changes made outside it. A
periodic reconciliation sweep is the only thing that recovers a missed event.
MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content
hash, a node id and an endpoint — no paths, no filenames — and private groups
register nothing (H7).
Stage B — one implementation behind every front door
----------------------------------------------------
C1 and C6 were both "a second path into the node with its own weaker
handshake". Two implementations of `revoke` with two authorization checks is
that shape one size down. `meshbay_node/ops.py` holds each operation once,
takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback
API is one `_op(...)` line per endpoint; the MNP handlers call the same
functions. test_ops.py asserts the shape rather than trusting it.
Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload`
(SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a
browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP
operations: rotation is the half of revocation that revocation cannot do, since
the ex-member holds the current key, and the node generates the replacement
with its own CSPRNG — no key material crosses the wire, which is what the C5b
rule is actually about.
Two bugs found by running it rather than by testing it
------------------------------------------------------
GroupIndex is keyed by **content hash**, so the same bytes at two paths are one
entry — which is also why a scan reports ten files and indexes nine.
Reconciliation compared paths, so it decided the second path was a missed event
every 60 s, rewrote the entry and pushed an index update to every connected
peer. Seen in a live node's log.
`meshbay-node reload` crashed on first use with `subprocess` unimported: the
module compiles fine, which is the "syntax, not names" trap already recorded for
the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be
added to the parser without an entry there.
Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the
wire carried "0.2" — harmless only because nothing imported it. And
_do_dir_create/_do_dir_delete referenced an undefined `filename` on their error
path.
740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
12 files changed, 45 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index cb7a462..4a4e712 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1240,6 +1240,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, // Directories are not index entries, so a new empty one needs a nudge // to appear in the breadcrumb listing. const [nodeDirs, setNodeDirs] = useState([]); + // The group's roots and whether each is readable. A root whose drive is + // unplugged keeps its files listed — they are frozen, not deleted — so this is + // the only thing that lets the UI say which of the two it is. + const [nodeRoots, setNodeRoots] = useState([]); const [isNodeAdmin, setIsNodeAdmin] = useState(false); // Paired ≠ operator account. `is_node_admin` says the hub account owning this @@ -1275,6 +1279,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const fresh = indexMsg.entries || []; setEntries(fresh); if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); + if (indexMsg.roots) setNodeRoots(indexMsg.roots); cacheGroupIndex(groupId, group ? group.name : groupId, fresh); }, [groupId, group]); @@ -1492,6 +1497,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const indexMsg = await transport.fetchIndex(); if (indexMsg.entries) setEntries(indexMsg.entries); if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); + if (indexMsg.roots) setNodeRoots(indexMsg.roots); } catch (err) { setError(err.message); } @@ -1671,6 +1677,19 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, } const subdirs = [...dirs].sort(); + // At the top of a group the folders on screen ARE the roots, so their state + // belongs there. Deeper in, everything shown lives inside one readable root + // and there is nothing to flag. + const rootState = new Map(nodeRoots.map(r => [r.name, r])); + const unavailableHere = currentPath + ? [] + : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false); + // A member cannot create a folder at the top of a group: that level is the + // set of roots, which is the operator's configuration and not a directory on + // anyone's disk. The node refuses it, so offering it would only produce an + // error nobody can act on. + const canCreateDir = Boolean(currentPath); + const baseLabel = { idle: t('status.idle'), discovering: t('status.discovering'), @@ -1875,9 +1894,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, <input type="file" multiple style="display:none" onChange=${uploadFile} /> </label> + ${canCreateDir && html` <button class="tb-btn" onClick=${makeDirectory}> <${Icon} name="folder-plus" /> ${t('group.mkdir')} </button> + `} </div> <div class="breadcrumbs"> @@ -1944,8 +1965,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, onChange=${() => toggle(dirKey(d))} /> </td> `} - <td>\u{1F4C1}</td> - <td>${d}/</td> + <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td> + <td>${d}/${unavailableHere.includes(d) ? html` + <span class="root-offline"> ${t('group.root_unavailable')}</span> + ` : ''}</td> <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> <td class="td-type"></td> <td class="td-date"></td> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 15401bf..aaed0d1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -92,6 +92,7 @@ export default { 'group.upload': 'Hochladen', 'group.mkdir': 'Neuer Ordner', 'group.mkdir_prompt': 'Name des neuen Ordners:', + 'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)', 'group.view': 'Ansehen', 'group.delete': 'Löschen', 'group.delete_confirm': '{name} löschen?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a98eb1a..02532d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -92,6 +92,7 @@ export default { 'group.upload': 'Upload', 'group.mkdir': 'New folder', 'group.mkdir_prompt': 'Name of the new folder:', + 'group.root_unavailable': '(unavailable — the drive is disconnected)', 'group.view': 'View', 'group.delete': 'Delete', 'group.delete_confirm': 'Delete {name}?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 596d952..49bfc1c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -90,6 +90,7 @@ export default { 'group.upload': 'Subir', 'group.mkdir': 'Nueva carpeta', 'group.mkdir_prompt': 'Nombre de la nueva carpeta:', + 'group.root_unavailable': '(no disponible — la unidad está desconectada)', 'group.view': 'Ver', 'group.delete': 'Eliminar', 'group.delete_confirm': '¿Eliminar {name}?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 61dc99e..1afc5b1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -91,6 +91,7 @@ export default { 'group.upload': 'Envoyer', 'group.mkdir': 'Nouveau dossier', 'group.mkdir_prompt': 'Nom du nouveau dossier :', + 'group.root_unavailable': '(indisponible — le disque est déconnecté)', 'group.view': 'Afficher', 'group.delete': 'Supprimer', 'group.delete_confirm': 'Supprimer {name} ?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 6693115..44b4353 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -91,6 +91,7 @@ export default { 'group.upload': 'Carica', 'group.mkdir': 'Nuova cartella', 'group.mkdir_prompt': 'Nome della nuova cartella:', + 'group.root_unavailable': '(non disponibile — l’unità è scollegata)', 'group.view': 'Visualizza', 'group.delete': 'Elimina', 'group.delete_confirm': 'Eliminare {name}?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 8e18a19..33fdee3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -89,6 +89,7 @@ export default { 'group.upload': 'アップロード', 'group.mkdir': '新しいフォルダー', 'group.mkdir_prompt': '新しいフォルダーの名前:', + 'group.root_unavailable': '(利用不可 — ドライブが切断されています)', 'group.view': '表示', 'group.delete': '削除', 'group.delete_confirm': '{name} を削除しますか?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 6fd9220..aa0385e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -92,6 +92,7 @@ export default { 'group.upload': 'Uploaden', 'group.mkdir': 'Nieuwe map', 'group.mkdir_prompt': 'Naam van de nieuwe map:', + 'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)', 'group.view': 'Bekijken', 'group.delete': 'Verwijderen', 'group.delete_confirm': '{name} verwijderen?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 81f0139..e66dd12 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -95,6 +95,7 @@ export default { 'group.upload': 'Wyślij', 'group.mkdir': 'Nowy folder', 'group.mkdir_prompt': 'Nazwa nowego folderu:', + 'group.root_unavailable': '(niedostępne — dysk jest odłączony)', 'group.view': 'Podgląd', 'group.delete': 'Usuń', 'group.delete_confirm': 'Usunąć {name}?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index a2f41af..03de040 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -92,6 +92,7 @@ export default { 'group.upload': 'Enviar', 'group.mkdir': 'Nova pasta', 'group.mkdir_prompt': 'Nome da nova pasta:', + 'group.root_unavailable': '(indisponível — a unidade está desconectada)', 'group.view': 'Visualizar', 'group.delete': 'Excluir', 'group.delete_confirm': 'Excluir {name}?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 86c7580..72995dd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -88,6 +88,7 @@ export default { 'group.upload': '上传', 'group.mkdir': '新建文件夹', 'group.mkdir_prompt': '新文件夹的名称:', + 'group.root_unavailable': '(不可用 — 驱动器已断开连接)', 'group.view': '查看', 'group.delete': '删除', 'group.delete_confirm': '删除 {name}?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 1ba0ef0..6b0b6fe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1894,3 +1894,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .btn-primary:hover:not(:disabled) { background: var(--accent-hover); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + +/* A root whose volume is absent. Its files stay listed — they are frozen, not + deleted — so the row has to carry the difference. */ +.root-offline { + font-size: 0.85em; + font-weight: 500; + color: var(--warn, #d97706); + margin-left: 6px; +} +.dir-row .root-offline { white-space: nowrap; } |