From c2c49182e6d4f1f1ef3ed3ba5e8699912f9a27bd Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 19 Sep 2026 01:02:05 +0200 Subject: feat(node): per-file upload ceiling is an operator setting, default 8 GB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was a 4 GB constant in webrtc_server.py, the same on a small board and on a machine holding a library. Now max_upload_gb in node.toml, on the Node page and via `meshbay-node transfers max-size`, read from the transport context per chunk so a change reaches an upload already running. MESHBAY_DESIGN.md §6.4; §15.3 records a defect found beside it. Co-Authored-By: Claude Opus 5 --- docs/MESHBAY_DESIGN.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) (limited to 'docs/MESHBAY_DESIGN.md') diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 03ae77e..dcfc77b 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1326,9 +1326,15 @@ Five protections, and they are the substance: and `stat()` is itself case-insensitive on NTFS and exFAT, so this already holds there; - **strict chunk ordering**; -- a **size cap** — 4 GB per file. There is deliberately no aggregate quota yet, and - that gap is named in §15.3 rather than left to be discovered: a member can still - fill the operator's disk one capped file at a time; +- a **size cap** — 8 GB per file by default, and **the operator's to set** + (`max_upload_gb` in node.toml, on the Node page, or `meshbay-node transfers + max-size`), because the disk that fills is theirs and a Pi and a film server + are not the same machine. It is read from the transport context on every + chunk, so a change applies to an upload already running. A file past the + ceiling is refused at the chunk that crosses it and its `.part` is deleted. + There is deliberately no aggregate quota yet, and that gap is named in §15.3 + rather than left to be discovered: a member can still fill the operator's + disk one capped file at a time; - the target root must be **writable and available**, enforced by the node. **There is no quarantine subdirectory.** A folder appearing beside the operator's @@ -3062,7 +3068,8 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | **C4** for browser-using accounts | Open until the signed bundle opt-out ships (O3) | | **T3** for browser users | **Accepted permanently.** Removed for native clients, and that removal's value depends on reproducible builds | | **Hub identity pinning** (O13) | Nothing pins the hub's key. Bounded, because a substituted hub can neither read content nor ship code to a native client | -| **Aggregate upload quota** | Per-file caps exist; a per-user or per-group total does not | +| **Aggregate upload quota** | Per-file caps exist and the operator sets theirs (§6.4); a per-user or per-group total does not | +| **`max_concurrent_downloads` / `max_concurrent_uploads` are lost at startup** | The daemon builds the `defaults` dict it hands `roster.node_settings` without those two keys, so a node that has never had them set from the panel resolves both to `None`, assigns that back onto the config, and builds the transport with it — the pools then come up at `transfers.py`'s defaults and **what node.toml says about them is read and ignored**. The Node page and the CLI still work, because writing the setting is what creates the roster row the startup path then finds. Found while adding the per-file ceiling beside them, which is in that dict | | **A signed upload transcript** | Ownership is recorded by the node and verifiable by nobody else (§5.4). Making it provable is a transcript the uploader signs, stored with the entry — designed in outline, not built | | Forward secrecy in group chat | **Given up deliberately and on the record** (§4.5). If it becomes a requirement it belongs in 1:1 DM | | Metadata at the hub | Membership, and who posted in which group and when. A known leak, not a solved problem (§7.1) | -- cgit v1.2.3 From 3e7c7eba8e512df852da38a6b9ddd8467ec4d4e7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 19 Sep 2026 01:49:11 +0200 Subject: docs: add a quickstart and a user guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUICKSTART takes one Linux machine from downloaded packages to a working group with a second member. USERGUIDE covers using a group and running a node, for a reader who is not a developer. Windows stays in PACKAGING-GUIDE.md. Both are written from the code — the CLI, ops.py, the systemd units and the interface catalogue — rather than from the specification, and they state what is not built as readily as what is. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 8 + docs/MESHBAY_DESIGN.md | 15 +- docs/QUICKSTART.md | 380 ++++++++++++++++++++++ docs/USERGUIDE.md | 861 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1260 insertions(+), 4 deletions(-) create mode 100644 docs/QUICKSTART.md create mode 100644 docs/USERGUIDE.md (limited to 'docs/MESHBAY_DESIGN.md') diff --git a/CLAUDE.md b/CLAUDE.md index 1f837eb..540bd8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,14 @@ optional hub feature and are off on the reference deployment. `docs/` is either an operational guide, or a superseded document kept for its cross-references and carrying a banner that says so. +**`docs/QUICKSTART.md` and `docs/USERGUIDE.md` are the user documentation** — +written for somebody who is not a developer, Linux and the browser only. They +name CLI verbs, default values, setting names and interface labels, so a change +to any of those is a change to them: the previous user guide was retired +because it had drifted past the point where a reader could tell which half was +still true. What keeps them honest is that they state what is *not* built as +readily as what is (§15.2, §15.3); a feature that lands deletes its line there. + ## Repository structure ``` diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index dcfc77b..551c0ed 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -30,6 +30,8 @@ | Read | For | |---|---| | **this document** | the architecture, the trust model, and the reason each decision is what it is | +| `QUICKSTART.md` | one machine to a working group, for somebody who has installed nothing | +| `USERGUIDE.md` | using a group and running a node, for the person who does either | | `MESHBAY_NODE_PROTOCOL.md` | the MNP wire format, message by message | | `transfers-v1.md` | the transfer system's failure-mode analysis, kept because a synthesis cannot carry "every way a slot can be lost" | | `playlists.md` | the playlist design and its interface in full, with what building it corrected (§9.10) | @@ -38,10 +40,15 @@ | `PACKAGING-GUIDE.md`, `HTTPS.md`, `MAIL-SERVER.md`, `windows-build.md` | installation and server operations | | `CLAUDE.md` | project conventions and the engineering lessons that govern how changes are made | -**There is no user guide.** The one that existed described the system before -per-node identity, named roots and the sealed wire, and was retired rather than -repaired — a document a reader cannot tell the sound parts of is worse than none. -Writing a new one starts here. +**The user documentation is `QUICKSTART.md` and `USERGUIDE.md`** (written +2026-09-18). The guide that preceded them described the system before per-node +identity, named roots and the sealed wire, and was retired rather than repaired +— a document a reader cannot tell the sound parts of is worse than none. The +replacements are written from this document and from the code, address a reader +who is not a developer, and cover Linux and the browser; Windows stays in +`PACKAGING-GUIDE.md`. **They state what is not built as readily as what is** +(§15.2, §15.3), which is what keeps them from drifting into the same condition +as the one they replace. ### 0.2 Reference labels diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..9a418f2 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,380 @@ +# MeshBay — Quickstart + +This guide takes one Linux machine, turns it into a MeshBay node, creates a +group over a directory on it, and invites a second person. It assumes you can +use a terminal. It assumes nothing else. + +For what each step *means* — and for everything this guide leaves out — read +[`USERGUIDE.md`](USERGUIDE.md). + +> **Scope.** Linux only: Ubuntu/Debian and Fedora. Windows is covered by +> [`PACKAGING-GUIDE.md`](PACKAGING-GUIDE.md) and is not repeated here. This +> guide uses **meshbay.org** as the hub; running your own is +> [`PACKAGING-GUIDE.md`](PACKAGING-GUIDE.md) plus [`HTTPS.md`](HTTPS.md) and +> [`MAIL-SERVER.md`](MAIL-SERVER.md). + +--- + +## What you are building + +``` + your machine meshbay.org your friend + ──────────── ─────────── ─────────── + meshbay-node ◄─── signalling ────► hub ◄─── sign-in ───► browser + holds the files accounts, or desktop + holds the group key group list, client + decides who is served introductions + │ │ + └──────────── encrypted, peer to peer, direct ──────────────────┘ + (your files never touch the hub) +``` + +Three things, and it is worth knowing which is which before you start: + +| | What it is | +|---|---| +| **The hub** | An address book and a switchboard. It holds your account and your group's name, and introduces two machines to each other. It never sees a file, a message or a key. | +| **The node** | A daemon on *your* machine. It holds the directories you share and the key that encrypts them. It decides who gets served. This is the part you install. | +| **The client** | The web app the hub serves, or the desktop application. This is what you and your members look at. | + +--- + +## Before you start + +- [ ] **A Linux machine that stays on.** Your files are served from it, so a + laptop that sleeps is a group that is offline half the time. A spare + mini-PC, a home server or an always-on desktop is the right shape. +- [ ] **A directory to share**, with something in it. +- [ ] **An email address** you can read — account sign-up sends a code to it. +- [ ] **About half an hour**, plus however long a first index takes — which + depends on how much you are sharing, and does not need watching. +- [ ] **Terminal access** to that machine. Everything below works over SSH; no + graphical session is needed on the node. + +You do **not** need a static IP, a domain name, port forwarding, or a router +you control. Two residential ISPs have been measured working without any of it. + +--- + +## Step 1 — Download the packages + +From **https://meshbay.org/downloads**, take the ones for your +distribution: + +| | Ubuntu / Debian | Fedora | +|---|---|---| +| **`meshbay-common`** — required, install it first | `.deb` | `.rpm` | +| **`meshbay-node`** — the daemon that hosts your files | `.deb` | `.rpm` | +| `meshbay-client` — the desktop application, optional | `.deb` | `.rpm` | + +You need the first two. The desktop application is worth having if this machine +has a screen, but nothing in this guide requires it: a terminal and a browser +are enough. + +Put them in one directory — the next step installs them together. + +--- + +## Step 2 — Install + +Install **with your package manager, not with `dpkg -i` / `rpm -ivh`**. Both of +those ignore weak dependencies, and the video drivers that let this machine +re-encode a film without melting are weak dependencies. + +```bash +cd ~/Downloads # wherever you put them + +# Ubuntu / Debian +sudo apt install ./meshbay-common_*.deb ./meshbay-node_*.deb + +# Fedora +sudo dnf install ./meshbay-common-*.rpm ./meshbay-node-*.rpm +``` + +Add `./meshbay-client_*` to that line to install the desktop application too. + +`meshbay-common` carries everything the others need, so there is nothing else +to install and no Python environment to set up. **ffmpeg**, required for video +streaming, comes in as a recommended dependency of the command above; without +it the node refuses to start and says so. + +Check it landed: + +```bash +meshbay-node status +``` + +It will report that there is no keystore. That is the next step. + +--- + +## Step 3 — Create your account + +Open **https://meshbay.org** in a browser and register. + +Three things on that page are worth slowing down for: + +1. **Your passphrase is not a password.** It never reaches the hub. It + encrypts the copy of your identity keys that lives on every node you join, + and nobody — not the hub, not an operator, not us — can reset it. The floor + is 12 characters; **four unrelated words** is the shape that actually works. +2. **Your recovery key is shown exactly once.** It is a 32-byte secret that can + restore your access to your groups if you forget the passphrase. Put it in a + password manager before clicking past it. Lose both it and the passphrase and + your files are still there — but every group has to be rejoined by hand, with + a new identity. +3. **Verify your email.** A code arrives; the account is inactive until you + enter it. + +--- + +## Step 4 — Set up the node + +Back on the node machine: + +```bash +meshbay-node init +``` + +It asks for the hub URL (press Enter for `https://meshbay.org`) and your hub +username. Then it writes `~/.config/meshbay/node.toml`, creates the keystore, +and prints something like: + +``` +Node key: 7mK2p...base64...= +``` + +**Copy that key.** In your browser, on meshbay.org: **Settings → Link Node**, +paste it, submit. + +This is what lets the hub introduce your members to this machine. It is a +public key; it identifies the node and unlocks nothing. + +--- + +## Step 5 — Start the node + +```bash +systemctl --user enable --now meshbay-node +loginctl enable-linger $USER # keep serving when you are not logged in +``` + +`enable-linger` is not optional on a machine you reach over SSH. Without it, +systemd stops your user services when your session ends — the group goes dark +the moment you disconnect. + +Check: + +```bash +meshbay-node status +``` + +You want to see `daemon running`. If not: + +```bash +journalctl --user -u meshbay-node -f +``` + +### If your files live outside your home directory + +The node unit is hardened: it can only write inside `~/.config/meshbay` and +`~/.local/share/meshbay`. An external drive or a second partition needs a +drop-in, and it needs **both** lines: + +```bash +mkdir -p ~/.config/systemd/user/meshbay-node.service.d +cat > ~/.config/systemd/user/meshbay-node.service.d/paths.conf <<'EOF' +[Service] +ReadWritePaths=/mnt/library +RequiresMountsFor=/mnt/library +EOF +systemctl --user daemon-reload +systemctl --user restart meshbay-node +``` + +`RequiresMountsFor` matters as much as `ReadWritePaths`. The service runs in its +own mount namespace, so a volume mounted *after* it started is invisible to it — +and the symptom is a directory that reads as empty while everything else looks +correct. + +--- + +## Step 6 — Create the group + +In the browser, on meshbay.org: **Create group** in the sidebar. + +- **Name** — anything. It is shown to members as `name@yourusername`. +- **Join policy** — leave it on **Invite only**. The alternative, *Open*, lists + the group publicly and lets anyone in, including the hub. There are groups + that want that; a first one is not it. +- **Description** — optional, shown to members on their home page. + +The group now exists on the hub and has no files, no key and no host. The next +step is all three. + +--- + +## Step 7 — Hand the group to your node + +Back on the node machine. Use the same name you just typed: + +```bash +meshbay-node group add "Family Photos" --dir ~/Pictures/shared +meshbay-node reload +meshbay-node gek init --group "Family Photos" +``` + +What those three did: + +| | | +|---|---| +| `group add` | told the node to host that group, and made the directory its first shared folder. It is **read-write** by default, so members can upload into it. Add `--no-writable` if you want a published, read-only library. | +| `reload` | made the running daemon re-read its config without dropping anyone. | +| `gek init` | generated the group's encryption key. **Nothing works before this** — the key never leaves your node, and every member receives it wrapped for their own key, on every connection. | + +The node starts indexing immediately. A large library takes a while; you can +watch it with `meshbay-node status` or in the group page, which shows the +progress. + +Want more than one directory in the group? + +```bash +meshbay-node root add /mnt/library/films --name Films +meshbay-node root add /mnt/usb/archive --removable # external drive +meshbay-node root list +``` + +Roots added later are **read-only by default** — the opposite of the first one, +deliberately. Pass `--writable` if members should be able to upload there. + +--- + +## Step 8 — Pair your browser + +Your node does not take orders from the hub. Before your browser can invite +anyone or delete a file, the node has to recognise it: + +```bash +meshbay-node operator pair +``` + +It prints a code like `R3H8-TB6V`, valid 24 hours. In the browser, open your +group → **Members** tab → enter the code. + +The code never passes through the hub. That is the whole point of it: a hub +that could name your operator key could make itself your node's administrator. + +--- + +## Step 9 — Let people reach you + +WebRTC picks a fresh UDP port per connection, so there is no single port to +forward. What the node needs is to **accept unsolicited inbound UDP on your +LAN** — browsers announce themselves with an address the node cannot resolve, +so the browser must be able to call the node, not the reverse. + +```bash +# Ubuntu — your LAN, never "anywhere" +sudo ufw allow from 192.168.0.0/16 app "MeshBay Node" + +# Fedora — apply to the zone holding your LAN interface +sudo firewall-cmd --permanent --zone=FedoraWorkstation --add-service=meshbay-node +sudo firewall-cmd --reload +``` + +**Scope it to the LAN.** This profile opens UDP 1024-65535; it does not belong +in an internet-facing zone. Members outside your network are reached by hole +punching, which needs no open port at all. + +The node's own admin surface is not affected: it listens on loopback only, +behind a token, and is never network-exposed. + +--- + +## Step 10 — Invite someone + +Your friend needs a meshbay.org account first — send them to Step 3. Then, with +their username: + +```bash +meshbay-node member invite alice_dupont +``` + +You get: + +``` +INVITATION CODE K7P2-9WQX +valid until 2026-09-25T17:40:00Z +``` + +Send that code however you normally talk to them — message, mail, out loud. It +works once, for that account only, for that group only, and the hub never sees +it. You do not need to be online when they use it. + +You can do the same thing from the **Members** tab of the group, from the +browser you paired in Step 8. + +--- + +## Step 11 — What your friend does + +1. Signs in at **https://meshbay.org**. +2. The group is already in their sidebar — creating the invitation registered + their membership. +3. They open it and are asked: *"This node needs to recognise you."* They paste + the code. +4. That is it. Your node pins their key, wraps the group key for it, and their + files appear. + +They will not be asked again on that browser. A *different* browser on the same +node recovers the same identity with their passphrase alone. A different +*node* — someone else's group — asks for a code again, as first contact always +does. + +--- + +## Did it work? + +```bash +meshbay-node status +``` + +A healthy node reads roughly like this: + +``` +hub https://meshbay.org (user yourname) +node key 7mK2p...= +daemon running — ok +node_id 82.65.x.x:0 +groups 1 files 4213 peers 1 +config /home/you/.config/meshbay/node.toml + group Family Photos [private] 8f3c... + shared → /home/you/Pictures/shared (rw) +operator yourname key AAAC3NzaC1lZDI1… paired 2026-09-18T14:02:11Z +``` + +If a line is missing, `status` names the command that fixes it. The four that +catch most people: + +| Symptom | Cause | +|---|---| +| `daemon not running` | Step 5, or a startup error in `journalctl --user -u meshbay-node` | +| `operator NONE PAIRED` | Step 8 — invites and file deletion are refused until this is done | +| an `action needed:` block naming a group key | Step 7's `gek init` was skipped — `meshbay-node group list` shows it as `NO KEY` | +| Group shows files to you, nothing to a member | The member never redeemed a code, or the firewall in Step 9 | + +--- + +## Where to go next + +- **Point Videos, Music and Photos at their folders.** Group → Settings — as + many folders as each library needs. Until you do, those tabs stay empty and + say so. +- **Turn off the applications you do not want.** Group → Members → Applications. + Files and Settings always stay. +- **Add your other devices.** Your phone's browser, the desktop application: + each one asks to be recognised, and you approve it from a device already + linked. Nobody else is involved. +- **Read [`USERGUIDE.md`](USERGUIDE.md)** — what each application does, how + members are managed, what happens when someone leaves, what is private and + what is not, and what to do when something breaks. diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md new file mode 100644 index 0000000..9f7cea7 --- /dev/null +++ b/docs/USERGUIDE.md @@ -0,0 +1,861 @@ +# MeshBay — User Guide + +This guide is for people who **use** MeshBay and for people who **run** a node. + +If you have not installed anything yet, start with +[`QUICKSTART.md`](QUICKSTART.md) and come back here. + +**Contents** + +1. [What MeshBay is](#1-what-meshbay-is) +2. [Your account](#2-your-account) +3. [Your devices](#3-your-devices) +4. [Joining a group](#4-joining-a-group) +5. [Using a group](#5-using-a-group) +6. [Running a node](#6-running-a-node) +7. [Managing people](#7-managing-people) +8. [What is private, and what is not](#8-what-is-private-and-what-is-not) +9. [When something breaks](#9-when-something-breaks) +10. [Reference](#10-reference) +11. [Running your own hub](#11-running-your-own-hub) +12. [What this software does not do yet](#12-what-this-software-does-not-do-yet) + +--- + +## 1. What MeshBay is + +MeshBay gives a small group of people — a family, a household, a few friends — +private access to files that live on **somebody's own machine**, with +applications over them: a chat, a file explorer, a video library, a music +player, a photo album. + +It is not a public file-sharing network and it is not a cloud. Nothing is +uploaded to a company. The files stay where they already are. + +### The three parts + +**The hub** (meshbay.org, or one you run) holds accounts and the list of +groups, and introduces two machines to each other. Under a kilobyte of +connection setup passes through it. For a private group — the default, and what +this guide assumes — it never sees a file, a message, a file name or a key. §8 +says exactly where that stops. + +**The node** is a daemon on the machine that holds the files. It holds the +group's encryption key, and it is the only thing that decides who gets served. +Not the hub — the node. If the hub invented an account and put it in your +group, the node would still refuse it. + +**The client** is either the web application the hub serves, or the desktop +application you install. Both are the same interface. + +### The one idea that explains the rest + +> **Everything about a group lives on the machine that hosts it.** + +The files, the list of them, who has been let in, invitations, chat history, +which folders allow uploads, how each application is set up — all of it on that +machine. The hub keeps accounts, group names and who belongs to what, and +nothing more. + +That is why the person running the group sends you the invitation code rather +than the website. It is why a hub having a bad day loses nothing. And it is why +there is no "I forgot my group" button to press. + +### What a group is + +A group is **people + directories + applications**. + +``` + A group's contents, as members see it + ───────────────────────────────────── + / + ├── Films/ → /mnt/library/films on the operator's machine + ├── Music/ → /mnt/audio an external drive + └── Documents/ → /home/them/share read-write: members may upload +``` + +Each of those is a **root** — a named directory. Members see the names; they +never see, and never name, a path on the operator's disk. + +--- + +## 2. Your account + +Your account lives on a hub. It is a username, an email address, and a +passphrase — and the passphrase is the part worth understanding. + +### Your passphrase is not a password + +It never reaches the hub. Your browser turns it into two separate values: one +that proves who you are to the hub, and one that encrypts **your identity keys** +where they are stored — on each node you have joined. + +Two consequences: + +- **The hub can reset your sign-in. Nobody can reset your keys.** A hub + operator, a node operator and the authors of this software are all equally + unable to open your keys. There is no back door, no support ticket, no + recovery from a database. +- **A good passphrase is worth more here than anywhere else.** Twelve + characters is the minimum the sign-up page accepts; four unrelated words is + the shape that really protects you. The locked copy of your keys on each + machine you join is only as strong as what locks it. + +### Your recovery key + +Shown **once**, at sign-up. It is a long random secret rendered as words. Put it +in a password manager. + +What it is for: a second, recovery-encrypted copy of your identity is stored on +each node beside the passphrase-encrypted one. With the recovery key, a +passphrase reset also restores your access to your groups. Without it, a reset +restores your sign-in only, and every group has to be rejoined by hand with a +fresh code from its operator. + +If you missed it at sign-up, or you signed in from a new browser: **Settings → +Recovery key → Enter recovery key** adds the backup copies to every group whose +node is reachable. + +### Forgetting your passphrase + +**Sign-in → "Forgot your passphrase?"** You need your username **and** the +email address on the account — the page says the same thing either way, so if +no code arrives, one of the two did not match. + +A code arrives by email. Enter it with a new passphrase, and your recovery key +if you have one. + +A reset also signs out every device that had been signing in by itself, so each +one asks for your passphrase once more. That is on purpose: if you are +resetting because something may have gone astray, a laptop that still lets +itself in is the thing you want stopped. + +### Changing a passphrase you still know + +**Settings → Passphrase → Change passphrase.** + +Your keys are re-encrypted on every group whose node is online **before** +anything changes on the hub — so if it fails half way, nothing has changed. +Groups whose nodes were offline are listed by name afterwards; for each one, +ask its operator to unpin you and send a fresh code. + +### Deleting your account + +**Settings → Delete account.** It removes your account, memberships and +notifications from the hub, and frees your username. + +What it cannot reach is anything sitting on other people's machines: files you +uploaded stay where their operator keeps them, and each node remembers you +until its operator says otherwise. Connection logs are kept for a year, as the +law requires. + +--- + +## 3. Your devices + +You can use one account from several devices — a laptop browser, a phone, the +desktop application. Each one gets **its own key on each machine you join** — +they are not copies of one key, and the key you hold on one person's machine +means nothing on another's. + +### Adding a device + +The first time a new browser opens a group, it says *"This browser is not +linked to this node yet"* and offers a code. You then approve it **from a +device that is already linked** — Members tab → *"Linking a new device? Enter +the code it is showing."* + +No operator is involved and the code never passes through the hub — one of +your own devices vouches for the new one, which is what stops anybody else +adding a device to your account. + +A few limits: **five devices per account on each machine**, a code lasts an +hour, and five wrong codes in a row stop the attempt. + +### Removing a device + +**Settings** on the node's group, or the Members tab: *Remove*. It loses access +to that node until it is linked again. Removing a device also moves the chat +key on, so it cannot read anything written from then on. + +### One passphrase, several browsers + +A second browser on a machine you have already joined picks up the *same* +identity: it asks for your passphrase, unlocks the copy of your keys kept +there, and you are in. No second code, nobody to ask — which is another reason +the passphrase is worth choosing well. + +### The desktop application + +Worth installing if you use MeshBay more than occasionally: + +| | Browser | Desktop application | +|---|---|---| +| Install | none | a package | +| Interface comes from | the hub, on every visit | inside the package, from disk | +| Keys | in the browser, plus a copy on each node | in the OS keyring, never bundled anywhere | +| Downloads | to disk where the browser allows it | native, streamed, no size limit | +| Casting to a TV | — | yes | + +The difference that matters: a browser fetches its code from the hub every time +you open it, while the application carries its own and never asks the hub for +any. So the application is the one to prefer for anything you care about. The +browser stays, and is a perfectly reasonable way to use MeshBay — being able to +open a group on someone else's laptop with nothing installed is worth having. + +--- + +## 4. Joining a group + +Someone who runs a node invites you. You need an account on the same hub first. + +1. **They send you a code** — eight characters like `K7P2-9WQX`, by message, + mail, or read out loud. It is good for 7 days by default, works once, and + only for your account in that one group. +2. **You sign in**, and the group is already in your sidebar. +3. **You open it.** It says *"This node needs to recognise you"*. Paste the + code. +4. Done — the machine hosting the group recognises you from now on, and the + files appear. + +You will not be asked again on that browser, or on that machine. + +**The code never passes through the hub**, and that is the whole reason it +exists: it proves the invitation came from the person running the group, and +not from the service in the middle. + +### Leaving + +Group menu → **Leave group**. You lose access to its files and chat. Anything +you uploaded stays on the machine hosting the group, which also remembers you +until its operator says otherwise. + +--- + +## 5. Using a group + +A group is a set of tabs. Which ones you see is the operator's choice; **Files** +and **Settings** are always there. + +### Files + +The explorer. Roots are the top-level folders; below that it behaves like any +file browser — sort, select, download, preview. + +- **Upload** appears only where the current folder is in a **read-write** root. + You can also drag files and whole folders onto the file list. +- **A drop is decided before anything is sent.** A name already in the folder, + or a name the node cannot store, cancels the whole drop and tells you which + name caused it — so you never end up with half a copy. +- **Download a folder as a zip**, built in your browser from the same encrypted + chunks as any other download. The node never compresses anything. +- **Delete** — you can remove a file you uploaded yourself; anything else is + the operator's to remove. A folder has to be empty first, so nothing here can + sweep away files you cannot see. + +### Chat + +Per-group messages, always encrypted, with threads, attachments and link +previews. + +- **Attachments** need a read-write folder in the group. Where there is none, + the paperclip is off and says so. +- **Link previews** are fetched **by the node** — the machine hosting the group + makes a request to whatever site somebody linked. The operator can turn it + off. +- **History is kept on the node**, and stays readable to members. A member + removed from the group cannot read anything written after their removal. +- Sometimes a message reads *"Written before this device could read this + conversation"* — that is a device added later, not an error. A message marked + *"the signature does not match the sender"* is different and worth asking + about. + +### Videos + +A poster browser over the folders the operator chose for it. Two modes: a +poster grid with artwork and summaries, and a plain folder-driven list that +needs no third-party service at all. + +- **Nothing appears until the operator chooses at least one folder** — the tab + tells you so, rather than just looking empty. +- Films and shows are **one card each**, not one per file; a show expands into + seasons and episodes. +- The operator can **correct a wrong match**, and the correction applies to the + whole show rather than one episode. +- Streaming has **seeking, audio-track selection and subtitles**. Picture-based + subtitle tracks (the ones stored as images rather than text) are not offered: + they would need text recognition to display, so they are left out rather than + listed and blank. +- A library is read **a page at a time**; the page size is your own preference + in Settings, not the group's. + +### Music + +An album browser and a player, over the folders the operator chose for it. + +- Tags in your files come first, then folder and filename, then a lookup for + cover art or canonical spelling. +- **The player survives leaving the tab** — go to Files, go to another group, + the music keeps playing. +- **Playlists are yours, not the group's**, and follow you across groups and + devices. Favourites, queues saved as playlists, drag to reorder. + +### Photos + +An album browser over the folders the operator chose for it, where **an album +is a folder**. Thumbnails come from the node, already rotated correctly. + +**Location data is never shown.** Photos shows when a picture was taken and +what took it, and no coordinates anywhere. Worth knowing, though: the +coordinates are still inside the photo itself, as your phone wrote them, so +anyone who downloads the original file has them. Strip them before sharing if +that matters to you. + +### Search + +The magnifying glass in the sidebar searches **across every group you are in** +at once, with a Files / Videos / Music / Photos view each. + +A file that two groups both have is **one result**, not two — identity is the +content, not the path. Each result says which group it came from, or how many. + +An operator can keep a group out of Search. It is a tidiness setting and +protects nothing: members still see everything by opening the group. + +### Transfers + +The transfers panel shows what is moving. Downloads queue, run, pause and +resume. + +- The node runs a limited number at once, and each member has their own share + so nobody can take the machine. Queued transfers say what they are waiting + for. +- **Pausing needs a download folder** chosen in Settings → Downloads. Without + one, your browser decides where files go and a transfer cannot be resumed. +- **Browsing is never queued.** Posters, thumbnails, opening a photo or a + document to look at it — none of it takes a transfer slot. A busy group + browses exactly like an idle one. + +### Casting to a TV + +**Desktop application only.** A film playing in the application can be sent to a +cast-capable TV or dongle on the same network: *Cast to device* in the player, +pick one from the list. + +The application decrypts the film and relays it to the TV itself, over your LAN. +The TV is not a group member and holds no key — which is also why the relay's +ports have to be reachable on the local network (§10). + +Subtitles travel with it. Other TV protocols — the UPnP/DLNA family — are +designed and not built. + +### Notifications and settings worth knowing + +**Settings → Downloads** — save automatically to a folder, or ask every time. +**Settings → Defaults** — which tab a group opens on, how many items per page. +**Settings → Appearance** — theme and language (ten languages ship). +**Group menu → Mute** — stop notifications for one group. + +--- + +## 6. Running a node + +This section is for the person whose machine holds the files. + +### What you decide + +Your node, not the hub, decides who gets served and what gets served — from a +list you build yourself with invitation codes. Two things follow from that: + +- The hub can tell somebody your group exists and point them at your machine, + but it cannot get them let in. Only your invitation does that. +- **Your own browser counts as a stranger until you pair it** + (`meshbay-node operator pair`). Until then it cannot invite anyone or delete + anything — which is a little surprising the first time, and is what keeps the + decision yours. + +### Two ways in + +| | | +|---|---| +| **The CLI**, over SSH | no browser needed, and it works while the daemon is stopped | +| **A paired browser** | the group's Settings and Members tabs, and the Node page in the desktop application | + +On a headless server the CLI is the only path, and it covers everything you +need to run a group. One gap is known: removing **one** device of one member is +only doable from the interface (§7). Start with: + +```bash +meshbay-node status +``` + +It reports what is configured, what is running, and — when something is +missing — the command that fixes it. + +### Directories (roots) + +A group's content is a set of **named directories**. The name members see is +the directory's own basename, fixed when you add it. + +```bash +meshbay-node root list +meshbay-node root add /mnt/library/films --name Films +meshbay-node root add /mnt/usb/archive --removable +meshbay-node root set Films --writable +meshbay-node root remove Films +``` + +Rules that will bite you if you do not know them: + +- **Two roots cannot share a basename**, even on different drives. `/mnt/a/Films` + and `/mnt/b/Films` is refused; name one of them explicitly. +- **No root inside another.** The same bytes would be indexed twice under two + identities. +- **Renaming a root rewrites every path under it**, so it is an explicit act, + not a cosmetic one. +- The first directory of a new group is **read-write**; every root added later + is **read-only** unless you say otherwise. + +### Read-only and read-write + +A read-only directory is read-only **for everybody, including you**. That is +intentional: a library you have published as read-only should stay as you +arranged it, and an accidental drag-and-drop from your own browser is as +unwelcome as anyone else's. + +Whether uploads are allowed is decided per directory and nowhere else. A group +can have several directories open for uploads, or none at all — a group nobody +can add to is a perfectly normal thing to want. + +### External drives + +Mark a root `--removable` and you get **Eject** and **Plug in** beside it. + +Eject before you unplug. It stops watching that directory and freezes its files +in the index — they stay listed and are reported as unavailable, rather than +looking to every member as though you deleted your library. Plug in checks the +path is really back, then rescans. + +If a removable root's path disappears without an eject, the node ejects it for +you. Nothing is deleted: index entries, thumbnails, metadata and chat history +referring to those files all survive. + +Remember the systemd drop-in for anything outside your home directory — both +`ReadWritePaths` and `RequiresMountsFor`. It is in +[`QUICKSTART.md` Step 5](QUICKSTART.md#step-5--start-the-node). + +### Indexing + +The node watches its directories and re-checks them periodically — the periodic +pass is not a backstop, it is required, because filesystem events are dropped +under load and are unreliable on network and FUSE mounts. + +Files over 40 MB are identified by reading their beginning, end and middle +rather than the whole file. A 4 TB library does not need 4 TB of reading to be +catalogued. + +Group → Settings has the re-check interval and how long the node waits after a +file changes before indexing it. Members see indexing progress while it runs. + +One thing that surprises people: **two identical files in one group are one +entry**, because a file is identified by its content. A scan that reports ten +files and indexes nine has not lost anything. + +### Applications and their folders + +Group → **Members → Applications** turns applications on and off for everyone. +Files and Settings always stay. + +Group → **Settings** points each application at folders: + +- **Videos**, **Music** and **Photos** — as many folders as you like for each. + A library is rarely in one place, so pick every folder that belongs to it and + leave out the rest. Each tab stays empty until at least one is chosen. +- **Chat** — one folder, for attachments. It has to be one members can write + to. + +This is not a personal view: it decides what **everyone** in the group sees in +that tab. The change is signed by your paired browser and reaches connected +members straight away. + +### Metadata from third parties + +**Videos** uses a film and TV database for posters, summaries and cast. It ships +with a default credential and you can set your own; the language is a +group-wide setting, because there is one shared cache rather than a request per +viewer. Turn it off and Videos falls back to thumbnails and cleaned filenames, +with no outside request at all. + +**Music** uses a music database for cover art and canonical spelling when a +track carries none. It needs no credential. + +Both lookups are made **by the node**, once per title, for everybody. Your +members' devices never talk to a third party. + +### Node settings + +Group Settings covers a group; **Node settings** covers the machine. From the +desktop application's Node page, or by editing `~/.config/meshbay/node.toml` +and running `meshbay-node reload`: + +| Setting | Default | What it decides | +|---|---|---| +| `invite_ttl_hours` | 168 (7 days) | how long an invitation stays usable | +| `pair_ttl_hours` | 24 | how long an operator pairing code lasts | +| `device_request_ttl_minutes` | 60 | how long a device request waits for approval | +| `max_concurrent_streams` | 8 | how many people can watch video at once | +| `max_concurrent_downloads` | 8 | node-wide download slots | +| `max_concurrent_uploads` | 8 | node-wide upload slots | +| `max_upload_gb` | 8 | the largest single file a member may send you | +| `transcode_incompatible_video` | true | re-encode video no browser can play | +| `hardware_video_encode` | true | use the GPU for that, if one actually works | + +`max_concurrent_streams` is the one to think about: a stream costs a video +process for as long as the film lasts, so this counts **viewers**, not +requests. + +`max_upload_gb` is the only one of these about your disk rather than this +machine's work. Fractions are allowed — `0.5` is 512 MB — and the new ceiling +applies to an upload already in progress, so raising it unblocks a file that +was about to be refused. There is no total quota behind it: a member who can +write to a folder can still fill the disk one capped file at a time. + +Turning transcoding off does two different things depending on the video: one +your viewers' browsers can decode by themselves plays as it is, and one they +cannot is refused with a message pointing at this setting. + +Changes are saved to the database for immediate effect **and** written back +into `node.toml` so they survive a reinstall. Your hand-written comments in +that file are left alone. + +--- + +## 7. Managing people + +### Inviting + +```bash +meshbay-node member invite alice_dupont +``` + +Or the group's **Members** tab, from a paired browser. + +They need an account on the same hub first. The invitation registers their +membership on the hub and produces a code that never goes near it. Send the +code out of band; they enter it the first time they open the group. You do not +need to be online then. + +### Seeing who is in + +```bash +meshbay-node member list +``` + +Names, roles, when each identity was pinned and how, plus any pending +invitations. + +### Removing someone + +```bash +meshbay-node member revoke alice_dupont +meshbay-node gek rotate --group "Family Photos" +``` + +**Both lines, and the second one matters.** Revoking means the node stops +handing them the group key on their next connection. It does not take back the +key they already have — no protocol can. Rotating replaces it: every member +still in the group gets the new one automatically, and the person you removed +keeps the old one, which opens nothing written from now on. + +Anything they already downloaded stays theirs. Once a file has been copied, no +software can reach back and take it away. + +If you mistype the username, `member revoke` says so rather than quietly doing +nothing. + +### Unpinning, and when to use it + +```bash +meshbay-node member unpin alice_dupont +``` + +This forgets **every device** of theirs on this node, so they can pair again +with a new key. It is the fix for two specific situations: + +- Someone lost their passphrase and had no recovery key. Unpin, then send a new + invitation code. +- Someone changed their passphrase while your node was offline, and their group + is now unreadable to them. + +### Devices + +A member's devices are managed **from the interface, not from the CLI** — their +own *Your devices on this node* list, or your group's Members tab from a paired +browser. A removed device is remembered as removed, so the same key cannot +quietly reappear later. + +`meshbay-node member unpin ` is the CLI's blunt version: it removes +**every** device that person has on this node, and they pair again from scratch. +There is no per-device CLI verb today — noted in +[`MESHBAY_DESIGN.md` §15.3](MESHBAY_DESIGN.md#153-open-and-why-each-is-where-it-is). + +### Rotating the group key + +```bash +meshbay-node gek rotate --group "Family Photos" +``` + +Connected members get the new key with no action on their part. Anyone revoked +keeps the old one and loses everything from that point on. Nothing already +downloaded is affected. + +### The audit log + +Every admission, refusal, invitation and deletion is recorded on the node. The +desktop application's Node page shows it and exports it as CSV. + +--- + +## 8. What is private, and what is not + +**The short version: your files stay on the machine that hosts them.** They +travel encrypted and directly to the people that machine has been told to +serve. The hub that introduced everybody never holds them, and cannot read +them. + +For most people that is the whole answer. The rest of this section is for +deciding what belongs in a group and what is better kept elsewhere. + +### What you can rely on + +- **Nothing goes through the hub** — not your files, not their names, not even + the list of them. Your devices talk to the node directly. +- **The hub cannot open your group.** It holds none of the keys, and for a + private group it does not know what is in it. +- **The hub cannot let anybody in.** Only the node decides who it serves, from + the list its operator built with invitation codes. An account the hub added by + itself gets nowhere. +- **Only your own devices can add another of your devices.** The hub cannot, + and neither can an operator. +- **You get separate keys in each group you join**, so two people hosting you + cannot work out that you are the same person. +- **Chat history is encrypted where it is stored.** +- **Your client remembers each node** and refuses one whose identity has + changed — which is what stops a machine being quietly swapped for another. +- **People you have already seen in a group stay checked** every time they post + after that. + +### Worth knowing before you share something + +- **Whoever hosts a group can read what is in it.** The files sit on their + machine in the ordinary way, because that is what hosting is. It is the same + trust you extend to a friend keeping a spare key to your flat — reasonable, + and worth being conscious of. +- **Everyone in a group sees everything in it.** There is nothing below group + level: no per-person permissions, no private corner inside a shared library. + If two sets of people should not see the same things, make two groups. +- **The desktop application protects you a little better than a browser.** A + browser downloads its code from the hub every time you open it; the + application carries its own and never asks the hub for any. For anything you + would rather not stake on the hub behaving, prefer the application. +- **Your passphrase is what guards your keys.** A copy of them, locked with it, + sits on each machine you have joined. A long one puts that out of reach; a + guessable one does not. This is the single thing most worth getting right. +- **An open group is open.** Anybody can walk into one, which is what open + means. Invite-only is the default, and is what you want for anything personal. +- **The hub sees who is in which group, and when.** Never what was said or + shared — but the pattern of it is visible to whoever runs the hub. +- **Deleting your account does not delete what you shared.** Files you uploaded + stay on the machines hosting them, and each node remembers you until its + operator says otherwise. Ask them if it matters. + +## 9. When something breaks + +### Start here + +```bash +meshbay-node status +journalctl --user -u meshbay-node -f +``` + +`status` works whether or not the daemon is running, and names the command for +anything missing. + +### The common ones + +**"No nodes are currently online for this group."** +The node is not running, is not reachable, or has not linked its key to the +hub. On the node: `meshbay-node status`, then Step 4 and 5 of the quickstart. + +**A member sees the group but no files.** +They never redeemed an invitation code, or the group has no key +(`meshbay-node gek init`), or nothing has been indexed yet. + +**Members on other networks connect, members on your own LAN do not.** +The firewall. A browser on your LAN has to be able to call the node, and a node +refusing unsolicited inbound UDP cannot be called. See +[`QUICKSTART.md` Step 9](QUICKSTART.md#step-9--let-people-reach-you). + +**A shared directory reads as empty and everything else looks right.** +A mount the service cannot see. The systemd drop-in needs +`RequiresMountsFor` as well as `ReadWritePaths` — the service has its own mount +namespace, so a volume mounted after it started is invisible to it. + +**"Invitations are signed with the key this node pinned for your browser."** +The browser is not paired. `meshbay-node operator pair`, then enter the code in +the Members tab. + +**A member changed their passphrase while the node was down.** +`meshbay-node member unpin `, then a fresh invitation code. + +**Video says the codec is not supported.** +The source codec has no decoder in that browser and transcoding is off, or +ffmpeg is missing. Check `transcode_incompatible_video` and that ffmpeg is +installed. + +**"Server busy" when starting a film.** +Every streaming slot is taken. `max_concurrent_streams` counts viewers, and +each one holds a slot for the whole film. + +**A download stops near the end in the browser.** +Choose a download folder in Settings → Downloads, which switches the browser to +writing straight to disk, and retry. The desktop application does not have this +class of problem. + +**Nothing appears in Videos, Music or Photos.** +No folders have been chosen for that application yet. Group → Settings. The tab +says so rather than just looking empty, but it is easy to miss. + +### After changing config by hand + +```bash +meshbay-node reload # re-read node.toml without dropping anyone +meshbay-node restart-daemon # full restart — drops live streams +``` + +Prefer `reload`. A restart kills every connection, which for someone watching a +film looks like a failure they caused. + +--- + +## 10. Reference + +### Node commands + +``` +meshbay-node status what is configured and running +meshbay-node init first-time setup +meshbay-node reset erase all node state (destructive) + +meshbay-node group list|add|remove groups this node hosts +meshbay-node root list|add|remove|set directories in a group +meshbay-node root eject|plug removable drives +meshbay-node gek init|rotate the group's encryption key + +meshbay-node operator pair authorise a browser +meshbay-node member list|invite|revoke|unpin people + +meshbay-node file list|rm files, from the machine itself +meshbay-node chat status|rotate|encrypt-history|prune +meshbay-node transfers show|set|max-size|per-member + transfer limits, and the + largest single upload +meshbay-node denylist show|clear +meshbay-node stun list|add|remove|reset NAT traversal servers +meshbay-node video rematch re-resolve video metadata + +meshbay-node reload re-read node.toml, hot +meshbay-node restart-daemon full restart +``` + +`--group ` selects a group where you host more than one. `--yes` skips +the confirmation on destructive commands. `man meshbay-node` has the full page. + +### Where things live + +| | | +|---|---| +| `~/.config/meshbay/node.toml` | configuration — hand-edited, commented, preserved | +| `~/.config/meshbay/node.env` | environment: keystore unlock, third-party tokens | +| `~/.config/meshbay/keystore.enc` | the node's own keys. **Back this up.** | +| `~/.config/meshbay/unlock.key` | what opens the keystore. Mode 0600. | +| `~/.local/share/meshbay/` | roster, indexes, chat, caches, thumbnails | +| `/opt/meshbay-common/venv/` | the shared Python environment | + +Losing the keystore means a new node identity: every group has to be re-linked +and every member re-admitted. It is small — back it up somewhere safe. + +### Ports + +| | | +|---|---| +| **inbound UDP, ephemeral** | peer connections. Scope the rule to your LAN. | +| **127.0.0.1:18000** | the node's own control API. Loopback only, behind a per-run token, never exposed. | +| **TCP 19550-19553, UDP 5353** | casting to a TV, desktop application only, LAN only | + +### Defaults worth remembering + +| | | +|---|---| +| Invitation code | 7 days, single use, one account, one group | +| Operator pairing code | 24 hours | +| Device linking code | 1 hour | +| Devices per account per node | 5 | +| Upload size limit | 8 GB per file, settable on the node | +| Transfers at once | 8 node-wide, 2 per member per group | +| Video streams at once | 8 | + +--- + +## 11. Running your own hub + +You do not need to. meshbay.org exists, is free, and sees nothing of your +content. Run your own if you want nobody else holding your account list, or you +want a hub that is not reachable from the internet at all. + +A hub is a Linux server with PostgreSQL, a reverse proxy for HTTPS, and +somewhere to send mail from. The steps are in: + +- [`PACKAGING-GUIDE.md`](PACKAGING-GUIDE.md) — install, database, configuration, first start +- [`HTTPS.md`](HTTPS.md) — the reverse proxy and certificates +- [`MAIL-SERVER.md`](MAIL-SERVER.md) — outgoing mail, which sign-up and recovery need + +Two things to know before you start. **Your hub serves the web application**, +so anyone using a browser against it is trusting you with their keys — the +same relationship you have with meshbay.org, pointed at you. And **a hub cannot +be moved**: accounts, group registrations and node links are all on it. + +--- + +## 12. What this software does not do yet + +Better to know now than to go looking for it: + +- **Packages are not signed**, and there is no signed repository yet, so there + is nothing to check a download against and no updates through your + distribution. Until that ships, take them from the download page and from + nowhere else. +- **There is no Android client.** A phone browser works. +- **A node cannot be hosted on Android**, and is not planned to be. +- **Hubs do not talk to each other yet.** Everyone in a group needs an account + on the same hub. +- **Casting reaches Chromecast devices** from the desktop application. Support + for other TV protocols is designed but not built. +- **Some subtitle tracks cannot be shown** — the ones stored as images rather + than text, roughly one embedded track in five. Displaying them would need + text recognition. +- **There is no overall storage quota.** Files are capped at 8 GB each — lower + or raise it on the node, above — but somebody can still fill a disk one file + at a time. Worth a glance now and then if you have opened a folder to people + you do not know well. +- **Searching the film database by hand has no per-member limit**, and it draws + on the node's own quota. Only likely to matter on a large group. +- **The record of who uploaded what belongs to the node.** It is kept and it is + accurate, but other members have no way to check it for themselves. +- **Chat history stays readable to everyone in the group**, including people + who join later. That is what makes it a shared history rather than + disappearing messages — but if a conversation should leave no trace, it does + not belong here. +- **Your client remembers each node's identity, but not the hub's.** It matters + less than it sounds: a stand-in hub still cannot read anything, and cannot + touch the desktop application at all. -- cgit v1.2.3 From 765214c22e7956f9add1501c69e00c918f3e5f2b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 19 Sep 2026 01:49:26 +0200 Subject: docs: correct the spec where it had drifted from the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by writing the user guide against the CLI rather than against this document. §6.7 listed a `member device` verb nobody ever wrote; §9.9 still called several folders a Photos-only shape after app_directories gave every application a set; §6.8 said five settings and the Node page shows eight; §5.5 counted one transfer pool where there are three. The per-device revocation gap is real and stays open — §15.3 names it as the capability it is, not as a documentation error. Co-Authored-By: Claude Opus 5 --- docs/MESHBAY_DESIGN.md | 57 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 14 deletions(-) (limited to 'docs/MESHBAY_DESIGN.md') diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 551c0ed..d44de56 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -296,9 +296,10 @@ comes from the hash binding: | Attempts per connection | 5, then a node-wide lockout | | Filing a request | requires the account to have at least one pinned identity already | -`member unpin ` removes **every** device. `device revoke` marks one rather -than deleting the row, because a deleted row is a key the node would happily pin -again. +`member unpin ` removes **every** device of an account, and is the only +form the CLI has (§6.7). Revoking **one** device is `revoke_device` over MNP, +driven from a device the node has already pinned, and it *marks* the row rather +than deleting it — a deleted row is a key the node would happily pin again. **Which device is on a connection** is proved separately from which account. The handshake proves the account; `device_hello` — signed over a transcript naming the @@ -683,7 +684,7 @@ unique `(device, nonce)` — a replay is a validly signed copy, so nothing about signature refuses it. **Epochs.** A new epoch is opened when, and only when, the set of devices that may -read *future* messages shrinks: `member revoke`, `member unpin`, `device revoke`, +read *future* messages shrinks: `member revoke`, `member unpin`, `revoke_device`, `gek_rotate`, or an explicit `chat rotate`. Epoch 1 is opened at group load — a group with no epoch is a group nobody can speak in. @@ -1012,7 +1013,7 @@ decisions: | Bound | Default | Why it exists | |---|---|---| -| Node-wide concurrent transfers | 8 | operator's machine | +| Node-wide concurrent transfers | 8 **per kind** | operator's machine. Downloads and uploads are **separate pools** (`max_concurrent_downloads`, `max_concurrent_uploads`, §6.8), because the two cost different things and one queue for both makes each cap mean nothing. Streaming is a third pool again (`max_concurrent_streams`): a member watching a film is not charged a download slot | | Per account, per group | 2 | absent means this, not "unlimited" | | Grant deadline | 30 s | a grant nobody takes up is a slot nobody can use | | Idle timeout | 120 s | catches a peer that vanished without the connection noticing | @@ -1513,26 +1514,36 @@ Two personas need different tools, and the headless one is the normal deployment | Desktop | the desktop client's Node page | | **Headless / SSH** | the CLI | -**Every operation is reachable over SSH with no browser on the host.** `status` +**Every operation is reachable over SSH with no browser on the host**, with one +exception named in §15.3 rather than left to be discovered: **revoking a single +device of a member is an interface action**, and the CLI's only lever is +`member unpin`, which removes all of that account's devices at once. `status` deliberately reads the keystore and config directly so it works while the daemon is stopped — the state an operator is most often in, since the daemon will not stay up before its key is linked or a group exists. ``` meshbay-node status -meshbay-node group add --dir [--no-writable] +meshbay-node group list|add|remove meshbay-node root list|add|remove|set|eject|plug meshbay-node gek init|rotate meshbay-node operator pair meshbay-node member list|invite|revoke|unpin -meshbay-node member device list|revoke -meshbay-node denylist show|clear meshbay-node file list|rm -meshbay-node chat status|prune|encrypt-history +meshbay-node video rematch +meshbay-node chat status|rotate|encrypt-history|prune +meshbay-node transfers show|set|max-size|per-member +meshbay-node denylist show|clear meshbay-node stun list|add|remove|reset -meshbay-node reload +meshbay-node reload|restart-daemon ``` +**This block is a list of verbs that exist.** It carried +`member device list|revoke` for as long as device linking has been built, and no +such verb was ever written — `device` falls through to the username resolver and +fails. A command list in a specification is read as a promise by whoever writes +the next document from it, which is exactly how it was found (§15.3). + `member revoke`/`unpin` resolve a username against the roster and **refuse an unknown one** rather than acting on nobody — a typo must not look like success. Revocation tells the operator what it does *not* do: the ex-member stops receiving @@ -1570,7 +1581,7 @@ a real capability reduction, not an oversight. ### 6.8 Node settings -Five `[node] `settings affect what the node does rather than how it starts, and +Eight `[node] `settings affect what the node does rather than how it starts, and their value is invisible until something goes wrong — so they are surfaced on the Node page: @@ -1580,8 +1591,16 @@ Node page: | `pair_ttl_hours` | 24 | how long an operator pairing code stays valid | | `device_request_ttl_minutes` | 60 | how long a device request waits for approval. Comfort, not security: the code is bound to the keys by its hash | | `max_concurrent_streams` | 8 | simultaneous video streams. One process per viewer, ~50 MB each; a slot is held for the length of a film, so this counts viewers | +| `max_concurrent_downloads` | 8 | node-wide download leases (§5.5) | +| `max_concurrent_uploads` | 8 | node-wide upload leases. A separate pool from downloads, because the two cost different things and one queue for both makes each cap meaningless | +| `max_upload_gb` | 8 | the largest single upload, §6.4. Fractions are allowed, and it is read per chunk so a change reaches an upload already running | | `transcode_incompatible_video` | true | whether browser-incompatible video is transcoded during streaming. Unlike remuxing this costs real CPU per viewer | +`hardware_video_encode` (default true) governs whether that transcode may use the +GPU. It is `node.toml` only and not on the page: the capability is established by +encoding and reading the result back (`hwaccel.py`), so the setting means "use it +if it works" and there is nothing for an operator to decide from a panel. + > **Turning transcoding off does not mean the same thing for every source.** A > codec with an MSE codec string falls back to a copy and the viewer's own decoder > decides; a codec with none has nothing to fall back to and the stream is refused, @@ -2388,14 +2407,23 @@ it. ### 9.9 Photos An album browser over the image files in the group's configured folders. It is -**smaller** than Videos and Music, deliberately, in three ways: +**smaller** than Videos and Music, deliberately, in two ways: -- **Several root folders rather than one.** - **One album-grid view, no mode toggle**, because there is nothing to fall back from. - **No third-party service at all** — there is nothing to match a photo *to*. It already is what it is, per its own folder and filename. +> **"Several root folders rather than one" used to be the third**, and is not a +> difference any more. The generic `app_directories` op (§9.3) gave every +> application a *set* of folders, so what Photos needed — a library scattered +> across directories — is what Videos and Music get as well. The pane that +> chooses them is the same shared widget in `multi` mode for all three; Chat's +> is the only `single` one, because an attachment is written to exactly one +> place. It is worth recording because the per-application shapes converging is +> the whole point of §9.3, and this section had been left stating the +> difference it removed. + An **album is a directory**, exactly as a season is a folder in Videos. Thumbnails are node-side, orientation-corrected, and delivered through the same chunk path. @@ -3084,6 +3112,7 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling and no root-availability check, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code | | **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted | | **Free-text third-party search** | `tmdb_search_req` takes a member's query and spends the operator's per-credential quota with no rate limit and no per-member bound, where link previews carry both. §6.5's standing rule — a bound and a named adversary in the same commit — was not applied here | +| **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run | | **Node announcements are not bounded** | One account may announce unlimited distinct node keys, each a row plus an IP-log row under a one-year retention. Proof of possession is checked (**M8**); the count is not | | **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so | -- cgit v1.2.3 From 933daccbcfde7705413d3a10db87d910c650ed42 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 19 Sep 2026 08:54:52 +0200 Subject: fix(node): node.toml's transfer pools reach the transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon built the defaults dict for `Roster.node_settings` by hand and left out `max_concurrent_downloads` and `max_concurrent_uploads`. Absent from the dict, both resolved to None, were assigned back onto the config, and the transport skipped them — so node.toml was parsed, validated, and then replaced by `transfers.py`'s own 8. Invisible to anyone who left the value at 8, which is the value the template suggests. There were three copies of that dict and they all disagreed: node_status' was missing those two and `max_upload_gb` besides. One builder now, `config.node_settings_defaults`, and the resolver's key list is a class attribute the tests hold it to, along with the writer's. 1429 passed against a baseline of 1423; the two new behavioural tests fail with the builder reverted. Co-Authored-By: Claude Opus 5 --- docs/MESHBAY_DESIGN.md | 1 - packages/meshbay-node/src/meshbay_node/config.py | 29 ++++++++ packages/meshbay-node/src/meshbay_node/daemon.py | 15 +--- packages/meshbay-node/src/meshbay_node/ops.py | 67 ++++++++--------- packages/meshbay-node/src/meshbay_node/roster.py | 53 ++++++++++---- .../tests/test_node_settings_defaults.py | 85 ++++++++++++++++++++++ 6 files changed, 184 insertions(+), 66 deletions(-) create mode 100644 packages/meshbay-node/tests/test_node_settings_defaults.py (limited to 'docs/MESHBAY_DESIGN.md') diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index d44de56..ce6a822 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -3104,7 +3104,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | **T3** for browser users | **Accepted permanently.** Removed for native clients, and that removal's value depends on reproducible builds | | **Hub identity pinning** (O13) | Nothing pins the hub's key. Bounded, because a substituted hub can neither read content nor ship code to a native client | | **Aggregate upload quota** | Per-file caps exist and the operator sets theirs (§6.4); a per-user or per-group total does not | -| **`max_concurrent_downloads` / `max_concurrent_uploads` are lost at startup** | The daemon builds the `defaults` dict it hands `roster.node_settings` without those two keys, so a node that has never had them set from the panel resolves both to `None`, assigns that back onto the config, and builds the transport with it — the pools then come up at `transfers.py`'s defaults and **what node.toml says about them is read and ignored**. The Node page and the CLI still work, because writing the setting is what creates the roster row the startup path then finds. Found while adding the per-file ceiling beside them, which is in that dict | | **A signed upload transcript** | Ownership is recorded by the node and verifiable by nobody else (§5.4). Making it provable is a transcript the uploader signs, stored with the entry — designed in outline, not built | | Forward secrecy in group chat | **Given up deliberately and on the record** (§4.5). If it becomes a requirement it belongs in 1:1 DM | | Metadata at the hub | Membership, and who posted in which group and when. A known leak, not a solved problem (§7.1) | diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index bf62639..2ae3c7c 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -366,6 +366,35 @@ def _read_roots(group: dict) -> list[RootSpec]: return specs +def node_settings_defaults(nd: NodeConfig | None = None) -> dict: + """ + The `node.toml` side of every setting the roster resolves. + + One function because there were three copies of this dict written by hand + and they disagreed. The daemon's left out both transfer pools, so on a node + whose operator had never touched the panel they resolved to None, were + written back onto the config, and the transport fell through to its own + defaults — `node.toml` parsed, validated, and then ignored. + + `test_node_settings_defaults.py` holds this against + `Roster.node_setting_keys()`, because a key missing here raises nothing + anywhere: it is a setting that stops working quietly. + """ + nd = nd or NodeConfig() + return { + "invite_ttl_hours": nd.invite_ttl_hours, + "pair_ttl_hours": nd.pair_ttl_hours, + "device_request_ttl_minutes": nd.device_request_ttl_minutes, + "max_concurrent_streams": nd.max_concurrent_streams, + "max_concurrent_downloads": nd.max_concurrent_downloads, + "max_concurrent_uploads": nd.max_concurrent_uploads, + "max_upload_gb": nd.max_upload_gb, + "transcode_incompatible_video": nd.transcode_incompatible_video, + "stun_servers": nd.stun_servers or list(DEFAULT_STUN_SERVERS), + "ice_interfaces": nd.ice_interfaces, + } + + def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: """ Load config from TOML file. Supports both single [group] and diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 1525205..478faae 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -293,19 +293,10 @@ class NodeDaemon: # Apply any roster overrides to node config (panel-edited values # take precedence over node.toml defaults). - from meshbay_node.config import DEFAULT_STUN_SERVERS + from meshbay_node.config import node_settings_defaults nd = self._config.node - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours, - "pair_ttl_hours": nd.pair_ttl_hours, - "device_request_ttl_minutes": nd.device_request_ttl_minutes, - "max_concurrent_streams": nd.max_concurrent_streams, - "max_upload_gb": nd.max_upload_gb, - "transcode_incompatible_video": nd.transcode_incompatible_video, - "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces, - } - effective = await self._roster.node_settings(defaults) + effective = await self._roster.node_settings( + node_settings_defaults(nd)) for k, v in effective.items(): setattr(nd, k, v) diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 9f0e6d1..b95ee2b 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -40,6 +40,7 @@ from meshbay_common.crypto import ( from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR from meshbay_node.roots import RootError, RootSet, off_disk +from meshbay_node.roster import Roster log = logging.getLogger(__name__) @@ -623,17 +624,11 @@ async def list_groups(state: dict) -> dict: members = await roster.list_members() has_operator = any(m["role"] == "operator" and m["status"] == "active" for m in members) - from meshbay_node.config import DEFAULT_STUN_SERVERS - nd = config.node if config else None - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours if nd else 168, - "pair_ttl_hours": nd.pair_ttl_hours if nd else 24, - "device_request_ttl_minutes": nd.device_request_ttl_minutes if nd else 60, - "max_concurrent_streams": nd.max_concurrent_streams if nd else 8, - "transcode_incompatible_video": nd.transcode_incompatible_video if nd else True, - "stun_servers": nd.stun_servers if nd and nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces if nd else [], - } + from meshbay_node.config import node_settings_defaults + # No config (a test, an unconfigured node) falls back to NodeConfig()'s own + # values rather than to numbers repeated here, which is the copy this used + # to be: it was missing three settings and reported them as null. + defaults = node_settings_defaults(config.node if config else None) if roster: settings = await roster.node_settings(defaults) else: @@ -1288,24 +1283,33 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict: # ── Node settings ──────────────────────────────────────────────────────────── +# What `set_node_settings` accepts, and how each value is validated. A module +# constant so a test can hold its key set against `Roster.node_setting_keys()`: +# this is the third list of the same settings, and the first two had already +# drifted apart once — the reader's defaults covered fewer settings than the +# resolver answered for, which is how node.toml's transfer pools came to be +# parsed and then ignored. The kinds here are the *writer's* validation and +# deliberately not the resolver's coercions. +NODE_SETTING_WRITERS: dict[str, tuple[str, str]] = { + "invite_ttl_hours": ("int", Roster.SETTING_INVITE_TTL), + "pair_ttl_hours": ("int", Roster.SETTING_PAIR_TTL), + "device_request_ttl_minutes": ("int", Roster.SETTING_DEVICE_TTL), + "max_concurrent_streams": ("int", Roster.SETTING_MAX_STREAMS), + "max_concurrent_downloads": ("int", Roster.SETTING_MAX_DOWNLOADS), + "max_concurrent_uploads": ("int", Roster.SETTING_MAX_UPLOADS), + "max_upload_gb": ("size", Roster.SETTING_MAX_UPLOAD_GB), + "transcode_incompatible_video": ("bool", Roster.SETTING_TRANSCODE), + "stun_servers": ("stun_list", Roster.SETTING_STUN_SERVERS), + "ice_interfaces": ("list", Roster.SETTING_ICE_INTERFACES), +} + + async def get_node_settings(state: dict) -> dict: """Return current effective node settings.""" - from meshbay_node.config import DEFAULT_STUN_SERVERS + from meshbay_node.config import node_settings_defaults roster = _roster(state) config = _config(state) - nd = config.node - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours, - "pair_ttl_hours": nd.pair_ttl_hours, - "device_request_ttl_minutes": nd.device_request_ttl_minutes, - "max_concurrent_streams": nd.max_concurrent_streams, - "max_concurrent_downloads": nd.max_concurrent_downloads, - "max_concurrent_uploads": nd.max_concurrent_uploads, - "max_upload_gb": nd.max_upload_gb, - "transcode_incompatible_video": nd.transcode_incompatible_video, - "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces, - } + defaults = node_settings_defaults(config.node) if roster: return await roster.node_settings(defaults) return defaults @@ -1317,18 +1321,7 @@ async def set_node_settings(state: dict, settings: dict) -> dict: nd = config.node conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) - allowed_keys = { - "invite_ttl_hours": ("int", roster.SETTING_INVITE_TTL), - "pair_ttl_hours": ("int", roster.SETTING_PAIR_TTL), - "device_request_ttl_minutes": ("int", roster.SETTING_DEVICE_TTL), - "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS), - "max_concurrent_downloads": ("int", roster.SETTING_MAX_DOWNLOADS), - "max_concurrent_uploads": ("int", roster.SETTING_MAX_UPLOADS), - "max_upload_gb": ("size", roster.SETTING_MAX_UPLOAD_GB), - "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE), - "stun_servers": ("stun_list", roster.SETTING_STUN_SERVERS), - "ice_interfaces": ("list", roster.SETTING_ICE_INTERFACES), - } + allowed_keys = NODE_SETTING_WRITERS set_by = state.get("node_user_id", "") updated = {} diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 474db31..0753400 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -988,34 +988,55 @@ class Roster: SETTING_STUN_SERVERS = "stun_servers" SETTING_ICE_INTERFACES = "ice_interfaces" + # Every setting this resolver answers for, and how a stored string becomes + # a value. `node_settings` iterates these two and nothing else, and + # `config.node_settings_defaults` is built from the same names — so the + # defaults dict cannot quietly cover fewer settings than are resolved. + # + # It did. The daemon's dict was written by hand and omitted the two + # transfer pools, so on a node with no panel override they resolved to + # `defaults.get(key)` → None, were assigned back onto the config, and the + # transport skipped them: `node.toml` was parsed, validated, and then + # replaced by the transport's own defaults. A missing key is not an error + # anywhere along that path — it is a setting that stops working in silence, + # and invisible unless the operator picked a value other than the default. + NODE_SETTING_SCALARS: tuple[tuple[str, str, str], ...] = ( + ("invite_ttl_hours", SETTING_INVITE_TTL, "int"), + ("pair_ttl_hours", SETTING_PAIR_TTL, "int"), + ("device_request_ttl_minutes", SETTING_DEVICE_TTL, "int"), + ("max_concurrent_streams", SETTING_MAX_STREAMS, "int"), + ("max_concurrent_downloads", SETTING_MAX_DOWNLOADS, "int"), + ("max_concurrent_uploads", SETTING_MAX_UPLOADS, "int"), + ("transcode_incompatible_video", SETTING_TRANSCODE, "bool"), + ("max_upload_gb", SETTING_MAX_UPLOAD_GB, "float"), + ) + NODE_SETTING_LISTS: tuple[tuple[str, str], ...] = ( + ("stun_servers", SETTING_STUN_SERVERS), + ("ice_interfaces", SETTING_ICE_INTERFACES), + ) + + @classmethod + def node_setting_keys(cls) -> frozenset[str]: + """Every key `node_settings` returns — what a defaults dict must cover.""" + return frozenset([k for k, _, _ in cls.NODE_SETTING_SCALARS] + + [k for k, _ in cls.NODE_SETTING_LISTS]) + async def node_settings(self, defaults: dict) -> dict: """Current effective settings: roster override if present, else config default.""" import json as _json result = {} - for key, setting in [ - ("invite_ttl_hours", self.SETTING_INVITE_TTL), - ("pair_ttl_hours", self.SETTING_PAIR_TTL), - ("device_request_ttl_minutes", self.SETTING_DEVICE_TTL), - ("max_concurrent_streams", self.SETTING_MAX_STREAMS), - ("max_concurrent_downloads", self.SETTING_MAX_DOWNLOADS), - ("max_concurrent_uploads", self.SETTING_MAX_UPLOADS), - ("transcode_incompatible_video", self.SETTING_TRANSCODE), - ("max_upload_gb", self.SETTING_MAX_UPLOAD_GB), - ]: + for key, setting, kind in self.NODE_SETTING_SCALARS: stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) if stored is not None: - if key == "transcode_incompatible_video": + if kind == "bool": result[key] = stored != "0" - elif key == "max_upload_gb": + elif kind == "float": result[key] = float(stored) else: result[key] = int(stored) else: result[key] = defaults.get(key) - for list_key, setting in [ - ("stun_servers", self.SETTING_STUN_SERVERS), - ("ice_interfaces", self.SETTING_ICE_INTERFACES), - ]: + for list_key, setting in self.NODE_SETTING_LISTS: stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) if stored is not None: try: diff --git a/packages/meshbay-node/tests/test_node_settings_defaults.py b/packages/meshbay-node/tests/test_node_settings_defaults.py new file mode 100644 index 0000000..f5d7856 --- /dev/null +++ b/packages/meshbay-node/tests/test_node_settings_defaults.py @@ -0,0 +1,85 @@ +""" +Three lists name the same node settings, and they must agree. + +The resolver (`Roster.node_settings`) answers for a set of keys; the reader +(`config.node_settings_defaults`) supplies what node.toml says for each; the +writer (`ops.NODE_SETTING_WRITERS`) says which may be set and how each is +validated. Nothing in the code path errors when the reader covers fewer keys +than the resolver answers for: the missing one resolves to None, is written +back onto the config, and the consumer falls through to its own default. So +node.toml is parsed, validated — and ignored. + +That is what happened to `max_concurrent_downloads` and +`max_concurrent_uploads`, and it was invisible because the value it fell back +to was the same 8 the file suggests. Only an operator who set something else +would ever have seen it, and then only as pools that did not match the file. +""" +import pytest +from meshbay_node.config import NodeConfig, load_config, node_settings_defaults +from meshbay_node.ops import NODE_SETTING_WRITERS +from meshbay_node.roster import Roster + + +def test_the_reader_covers_every_setting_the_resolver_answers_for(): + assert set(node_settings_defaults()) == set(Roster.node_setting_keys()) + + +def test_the_writer_covers_every_setting_the_resolver_answers_for(): + assert set(NODE_SETTING_WRITERS) == set(Roster.node_setting_keys()) + + +def test_no_default_is_none(): + """A None here is indistinguishable from a key that is missing.""" + missing = [k for k, v in node_settings_defaults().items() if v is None] + assert not missing + + +@pytest.mark.asyncio +async def test_node_toml_survives_startup_with_no_panel_override(tmp_path): + """The whole bug, at the level an operator meets it.""" + conf = tmp_path / "node.toml" + conf.write_text( + '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n' + '[node]\n' + 'max_concurrent_downloads = 3\n' + 'max_concurrent_uploads = 2\n' + 'max_concurrent_streams = 5\n' + 'max_upload_gb = 4\n', + encoding="utf-8") + nd = load_config(conf).node + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + effective = await roster.node_settings(node_settings_defaults(nd)) + finally: + await roster.close() + + assert effective["max_concurrent_downloads"] == 3 + assert effective["max_concurrent_uploads"] == 2 + assert effective["max_concurrent_streams"] == 5 + assert effective["max_upload_gb"] == 4.0 + + +@pytest.mark.asyncio +async def test_a_panel_override_still_wins_over_the_file(tmp_path): + conf = tmp_path / "node.toml" + conf.write_text( + '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n' + '[node]\nmax_concurrent_downloads = 3\n', encoding="utf-8") + nd = load_config(conf).node + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_node_setting(Roster.SETTING_MAX_DOWNLOADS, "6", "op") + effective = await roster.node_settings(node_settings_defaults(nd)) + finally: + await roster.close() + + assert effective["max_concurrent_downloads"] == 6 + + +def test_an_absent_config_falls_back_to_the_dataclass_not_to_literals(): + """`node_status` used to repeat the numbers by hand, and they went stale.""" + assert node_settings_defaults(None) == node_settings_defaults(NodeConfig()) -- cgit v1.2.3