aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md4
-rw-r--r--docs/MESHBAY_DESIGN.md36
-rw-r--r--packages/meshbay-client/src/main.js6
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py25
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py4
-rw-r--r--packages/meshbay-common/src/meshbay_common/chatbox.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/device.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/join.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-name.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js69
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js39
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/source-merge.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js32
-rw-r--r--packages/meshbay-hub/tests/harness/search_fanout_harness.mjs5
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py67
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py11
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py6
-rw-r--r--packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_no_index_cache.py98
-rw-r--r--packages/meshbay-hub/tests/test_password_change.py2
-rw-r--r--packages/meshbay-hub/tests/test_password_reset.py2
-rw-r--r--packages/meshbay-hub/tests/test_recovery_email.py2
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py2
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_files_unmerged.py4
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_source_merge.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_unlisted.py10
-rw-r--r--packages/meshbay-hub/tests/test_sticky_band_ring.py7
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/bundle_store.py4
-rw-r--r--packages/meshbay-node/src/meshbay_node/chat/store.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py77
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py64
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py31
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py36
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/musicbrainz.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py16
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py10
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py208
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py2
-rw-r--r--packages/meshbay-node/tests/conftest.py4
-rw-r--r--packages/meshbay-node/tests/test_audio_root_gates_enrichment.py9
-rw-r--r--packages/meshbay-node/tests/test_audio_transcode.py38
-rw-r--r--packages/meshbay-node/tests/test_bundle_store_recovery.py2
-rw-r--r--packages/meshbay-node/tests/test_chat_encryption.py2
-rw-r--r--packages/meshbay-node/tests/test_chat_history_binary.py2
-rw-r--r--packages/meshbay-node/tests/test_chat_multidevice.py5
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py2
-rw-r--r--packages/meshbay-node/tests/test_device_on_connection.py7
-rw-r--r--packages/meshbay-node/tests/test_disk_io_off_loop.py62
-rw-r--r--packages/meshbay-node/tests/test_enrich.py2
-rw-r--r--packages/meshbay-node/tests/test_enrich_photo.py2
-rw-r--r--packages/meshbay-node/tests/test_group_roster.py6
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py2
-rw-r--r--packages/meshbay-node/tests/test_musicbrainz.py5
-rw-r--r--packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py7
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py39
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py2
-rw-r--r--packages/meshbay-node/tests/test_season_and_search_requests.py9
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py12
-rw-r--r--packages/meshbay-node/tests/test_startup_scan_enrichment.py4
-rw-r--r--packages/meshbay-node/tests/test_stream_video_transcode.py4
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py12
-rw-r--r--packages/meshbay-node/tests/test_tmdb.py2
-rw-r--r--packages/meshbay-node/tests/test_tmdb_config_policy.py5
-rw-r--r--packages/meshbay-node/tests/test_tmdb_enabled_policy.py2
-rw-r--r--packages/meshbay-node/tests/test_tmdb_rematch_policy.py2
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_bound.py186
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_ladder.py4
-rw-r--r--packages/meshbay-node/tests/test_transfer_settings.py3
-rw-r--r--packages/meshbay-node/tests/test_video_root_gates_enrichment.py2
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py3
-rw-r--r--packaging/caddy/meshbay.org.Caddyfile8
-rw-r--r--packaging/conf/hub.toml.example4
-rw-r--r--packaging/systemd/meshbay-node-user.service4
-rw-r--r--packaging/systemd/meshbay-node.service14
-rw-r--r--packaging/win/build-win-light.ps111
-rw-r--r--packaging/win/build-win-msix.ps110
-rw-r--r--packaging/win/electron-builder.light.yml3
-rw-r--r--packaging/win/electron-builder.msix.yml20
114 files changed, 1176 insertions, 542 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 540bd8f..9aac263 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -149,7 +149,7 @@ that produced it.
| The node, the hub, the clients, the applications | §6, §7, §8, §9 |
| Structural decisions that are not revisited | §14 |
| What is built, what is not, what is open | §15 |
-| A reference to a document that no longer exists (`draft-v5 §5.2`, `apps.md §3`, …) | §16, the concordance — it maps every one onto its replacement section |
+| A reference to a document that no longer exists (`draft-v5 §5.2`, `apps.md §3`, …) — in git history, or in a document outside this repository | §16, the concordance — it maps every one onto its replacement section. The code itself cites `MESHBAY_DESIGN.md` and a section directly |
The documents under `docs/` that this replaced are kept and carry a banner
saying where their content went. **Where any of them disagrees with
@@ -839,7 +839,7 @@ here are kept only where they are a rule about *editing* the code.
| Shared settings widgets | `settings-ui.js`, `folder-tree.js` | **A pane must not import `group-settings.js`** — that is an import cycle, and it fails as a component that silently does not render |
| Transport, handshake, device hello, roster verify | `transport.js` | §5.2, §3.3 |
| Crypto | `crypto.js`, `keyderive.js` | §4 |
-| Hub session, token renewal, IndexedDB cache | `hub-client.js` | §3.1 |
+| Hub session, token renewal, IndexedDB (keys, playlists) | `hub-client.js` | §3.1. **No group index is stored**: the `group_indexes` store had no reader for weeks and is emptied on start-up (`purgeGroupIndexCache`) |
| Where the hub is | `platform.js` — `hubBase()` | **the only file allowed to decide this** (§8.3) |
| Downloads, decrypt pipeline | `file-utils.js`, `downloads.js`, `sw.js` | §8.5 |
| Video player | `video-player.js` — `pump()` is the only place credit is granted | §8.5 |
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index ce6a822..76c8b08 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2395,6 +2395,14 @@ extension-gated: two container formats tag perfectly and decode in no mainstream
engine, so for those the node performs a **one-shot whole-file conversion**, cached
under its own content hash and served through the ordinary chunk path.
+**That conversion has a size ceiling, and it is the cache's rather than
+memory's.** The result is one row in a 512 MB least-recently-used store sized for
+thumbnails, posters and subtitle tracks; a source hours long would be a single row
+that evicts most of the cache to fit and is evicted again by the next few
+thumbnails. Whole-file is the right shape for the few-megabyte sources this
+exists for, and the wrong one past that — serving audio of that length means
+streaming the conversion, which is a different feature and not this one.
+
**The player is persistent across tabs**, at shell level: closing the tab must not
stop the music. The next queued track is prefetched while one plays — client-side
only, a small in-memory cache evicted as the queue moves.
@@ -2545,6 +2553,14 @@ Two rules for a new application here:
function. A copy keeps agreeing until one of them changes, and the symptom is a
show whose episodes stream from two different nodes.
+**A group's index is never kept in browser storage.** The browser holds keys and
+playlists; it does not hold a copy of what a group contains. Such a cache existed,
+for a cross-group search that read it instead of dialling, and it outlived that
+search by weeks — writing a cleartext file listing that nothing read and no
+sign-out removed. Its only remaining use would be to draw a group's files while
+its node is unreachable, and that is refused on its own merits: a listing that
+cannot be opened is worse than an honest absence.
+
**A group whose node is down is the normal case, and nothing waits for it.** A
node is a machine in somebody's house, so with a handful of groups one of them
is always off. Each index is drawn the moment it arrives rather than when its
@@ -2923,7 +2939,7 @@ had already been asked.
| **AV6** | **A relay proves possession of its approved key.** A public key is not a password, and the register call is unauthenticated by design — it is not a user — so the proof is the only thing standing between a stranger and where nodes send relayed traffic |
| **AV7** | **A node bounds how many peers it holds and how long an unproven one lasts.** The hub's cap is per calling account, which is a limit on each member and not on the machine, so without this an operator's exposure grew with the size of their groups |
| **AV8** | **One account cannot make the hub mail another at will.** The invitation email's subject comes from the group row, never from the request, and the endpoint is metered |
-| **AV9** | **No mail is sent from the event loop.** `smtplib` is synchronous and waits up to ten seconds; called from an async handler that wait is the whole instance's, not one request's. Every send goes through `mail.send_off_loop`. **Argon2 is held to the same rule**: every derivation runs on one dedicated worker thread (`auth.*_off_loop`), never on the loop and never two at a time, because two concurrent `lanes=4` derivations deadlock in OpenSSL. **So is the node's disk**: every filesystem call on a group's content — the stat as much as the read, since a stat is what wakes a sleeping disk — goes through `roots.off_disk`, onto one worker thread per root set. A spun-down or network-mounted root answers its first syscall in seconds, and on the loop that is every group, every stream and the hub socket waiting for a platter |
+| **AV9** | **No mail is sent from the event loop.** `smtplib` is synchronous and waits up to ten seconds; called from an async handler that wait is the whole instance's, not one request's. Every send goes through `mail.send_off_loop`. **Argon2 is held to the same rule**: every derivation runs on one dedicated worker thread (`auth.*_off_loop`), never on the loop and never two at a time, because two concurrent `lanes=4` derivations deadlock in OpenSSL. **So is the node's disk**: every filesystem call on a group's content — the stat as much as the read, since a stat is what wakes a sleeping disk — goes through `roots.off_disk`, onto one worker thread per root set. A spun-down or network-mounted root answers its first syscall in seconds, and on the loop that is every group, every stream and the hub socket waiting for a platter. **ffmpeg's own output too**, through `asyncio.to_thread` rather than that per-root thread: a temp file is not a group root and has no platter to serialise against, but a whole transcode read inline is still tens of megabytes of blocking read |
| **AV10** | **Every path that makes the hub send mail is metered, per account.** A rate limit that counts by IP bounds a caller, not an inbox. Changing one's address mails an arbitrary stranger, so it carries a cooldown *and* a daily ceiling; a reset request and a registration resend carry cooldowns |
| **AV13** | **The mail server is not a relay, and `mail.py` is where that is decided.** Every message passes one function; `purpose` is keyword-required and checked against a closed list, so a helper that names anything else does not send and one that names nothing is a TypeError. Under it sit a bound per **recipient** — the thing a person being mail-bombed actually experiences, unmoved by which account, address or endpoint asks — and an instance-wide hourly ceiling, because registration is open and "per account" is a bound an attacker buys more of |
| **AV11** | **A namespace a client writes into is closed, and its rows are capped.** The preference key space is an allow-list plus `default_tab:<group_id>` checked as a group id, the value is length-bounded, and the row count per account is bounded |
@@ -2941,6 +2957,8 @@ had already been asked.
| **AV24** | **A node registered for no group is refused signaling, not exempted from it** (§7.2). The membership check was written as "if the node claims any group", so it skipped itself — membership, group status and the public-group gate together — for the node AV1 made commonplace: the unconfigured one, which is also the one least able to absorb the work |
| **AV25** | **Which nodes host a group is answered to its members** (§7.3). Only the public case checked, so a private group told any authenticated account that knew its id which machines hosted it — and an ex-member knows that id for ever |
| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). It is keyed by username, usernames are public, and so anyone can spend somebody else's attempts. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to one forced sign-in. The lockout is a DoS primitive by construction; this is the ceiling on it |
+| **AV27** | **A free-text third-party search is bounded per member and per node** (§6.5). `tmdb_search_req` spends the *operator's* credential, which TMDB rates and the whole group's automatic matching depends on, so one member holding a search box degrades the library for everyone. Per member and not per connection — three tabs is one person — and kept in the group context so a reconnect does not reset it. The refusal is an error, because an empty result list is what "no such film" looks like |
+| **AV28** | **How many node keys one account may announce is bounded** (§7.2). Each is a row plus an IP-log row under a one-year retention, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. Proof of possession (**M8**) settles whose key it is and not how many. Counted only where a row is added: re-announcing a key already held keeps working at the ceiling, or a node that reached it could never refresh its address again |
### 13.6 Chat design findings
@@ -3110,21 +3128,23 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| The exact-hash content check | Structural, not functional (§7.5) |
| **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 |
---
## 16. Concordance
-Code comments, tests and older documents cite sections of the documents this one
-replaces. **Those documents are no longer in the tree** — they were removed on
-2026-09-11, once their content was here and this table could resolve every
-reference they left behind. `git log -- docs/` recovers any of them.
+Older documents, notes outside this repository and every earlier revision in
+git cite sections of the documents this one replaces. **Those documents are no
+longer in the tree** — they were removed on 2026-09-11, once their content was here and
+this table could resolve every reference they left behind. `git log -- docs/`
+recovers any of them.
-Nothing needs editing to follow a reference: look the citation up here.
+The code does not need this table: since 2026-09-19 every comment and test cites
+`MESHBAY_DESIGN.md` and a section directly, and a new one must. What this
+resolves is what cannot be rewritten — a commit message, a `git blame`, a
+superseded document kept under `docs/`, a note somebody holds elsewhere.
| Cited as | Read |
|---|---|
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 755f1f7..794477b 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -15,7 +15,7 @@
* That value is realised by reproducible builds and published hashes (18.7),
* not by the packaging format.
*
- * See docs/desktop-client-v1.md §2 and §3.
+ * See docs/MESHBAY_DESIGN.md §8.1, §8.2.
*/
'use strict';
@@ -466,7 +466,7 @@ function secretsBackend() {
//
// Note what this key is *not*: it is not a per-node identity key. Those are
// generated per node, pinned there, and never leave that relationship
-// (docs/per-node-identity-v1.md). Nothing here correlates a person across
+// (docs/MESHBAY_DESIGN.md §3.2). Nothing here correlates a person across
// operators, and nothing wraps a group key for it.
const DEVICE_KEY = 'device_auth_ed25519';
@@ -1158,7 +1158,7 @@ function registerBridge() {
// .light.yml) ships no node-runtime extraResource at all; the renderer
// uses this to fall back to the browser-only "create a group" form
// instead of the wizard that assumes a local node it can link right there
- // (create-group-page.js) -- see C:\Users\admin\devel\light-client.md 5.3.
+ // (create-group-page.js).
// Unaffected off win32 and in dev: only a packaged Windows build can even
// be Light, so everything else keeps today's behaviour unconditionally.
function hasBundledNode() {
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index c5b0279..1d857fd 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -26,7 +26,8 @@ __version__ = "0.14.0"
# older client simply never sends/handles the new pair.
# 0.8: added `artist`/`album`/`track_no` to `IndexEntry`, and
# `musicbrainz_config`/`_ack`, `musicbrainz_enabled`/`_ack`,
-# `music_meta_req`/`_resp`, for the Music group app (docs/musicbay.md).
+# `music_meta_req`/`_resp`, for the Music group app
+# (docs/MESHBAY_DESIGN.md §9.8).
# Same shape as 0.5-0.7's Videos additions, and reuses `duration`/
# `thumb_hash`/`display_title` rather than declaring new ones. Additive:
# an older client simply doesn't render the new fields or send the new
@@ -38,29 +39,29 @@ __version__ = "0.14.0"
# client never sends the request, and simply can't play those two formats,
# same as before this existed.
# 0.10: added `audio_root`/`_ack` — the Music app's own entry point, same
-# shape as `video_root`/`_ack`. Reverses musicbay.md's original "no root,
-# whole shared tree" call: a real messy library showed that call was
-# wrong, and Music now requires one before showing/enriching anything,
-# exactly like Videos. Additive at the protocol level: an older client
-# never sends the op and never expects the field.
+# shape as `video_root`/`_ack`. Reverses the original "no root, whole
+# shared tree" call: a real messy library showed that call was wrong, and
+# Music now requires one before showing/enriching anything, exactly like
+# Videos (docs/MESHBAY_DESIGN.md §9.8). Additive at the protocol level: an
+# older client never sends the op and never expects the field.
# 0.11: added `taken_at`/`camera` to `IndexEntry` (best-effort, from a photo's
# own EXIF block) and `photo_roots`/`photo_roots_ack`, for the Photos group
-# app (docs/photos.md). Unlike `video_root`/`audio_root`, `photo_roots` is a
-# *set*, replaced whole in one signed op — a photo library is routinely
-# scattered across several folders, not one. Additive: an older client
+# app (docs/MESHBAY_DESIGN.md §9.9). Unlike `video_root`/`audio_root`,
+# `photo_roots` is a *set*, replaced whole in one signed op — a photo library
+# is routinely scattered across several folders, not one. Additive: an older client
# never sends the op and never expects either field.
# 0.12: added `link_preview_req`/`link_preview_resp` — the node unfurls a URL
# pasted in chat into an OpenGraph card. Additive: an older node logs "unknown
# type" and the client just shows the bare link, as it always did.
# 0.13: added `tmdb_rematch`/`tmdb_rematch_ack` — an operator dropping one
# file's cached TMDB match so it re-resolves with the current matcher
-# (§10.1/V13). Additive: an older node logs "unknown type", the client's
+# (V13). Additive: an older node logs "unknown type", the client's
# button just does nothing.
# 0.14: added an optional `bundle_enc_recovery` field on `keypair_bundle_store`
# and `keypair_bundle_resp` — a second copy of the identity bundle wrapped
# under the account's recovery key, so a forgotten passphrase does not strand
-# the identity (docs/auth-confirm.md §4.3). Additive: an older node ignores the
-# field on store and never returns one; an older client never sends it.
+# the identity (docs/MESHBAY_DESIGN.md §3.6). Additive: an older node ignores
+# the field on store and never returns one; an older client never sends it.
# 0.15: `file_chunk` and `index_sync` had forked between the transports — WebRTC
# sent binary, unsigned chunks and plain index entries, QUIC sent base64 chunks
# with a per-chunk Ed25519 signature and a `GroupIndex.serialize()` envelope. One
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 3e9fe11..20b5080 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -63,7 +63,7 @@ OP_SET_SCAN_SETTINGS = "set_scan_settings"
# shown before signing names the outcome and not the operation.
OP_TRANSFER_LIMITS = "transfer_limits"
# Whether the node uses the operator's own API token/language instead of the
-# shipped default — node-wide (docs/mediacenter.md §5.5), one credential
+# shipped default — node-wide (docs/MESHBAY_DESIGN.md §9.7), one credential
# shared by every group. Signed like the rest: it turns on outbound
# third-party network traffic that did not exist before the Videos app
# (§8) — an unsigned change would let any member alter egress the operator
@@ -119,7 +119,7 @@ OP_GROUP_ATTACH = "group_attach"
OP_GROUP_DETACH = "group_detach"
# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
# all: the node holds the GEK and wraps it itself, for a key the recipient proved
-# they hold (see `join.py` and docs/invite-pairing-v1.md). The operation existed
+# they hold (see `join.py` and docs/MESHBAY_DESIGN.md §3.4). The operation existed
# only to make member-supplied bundles safe, and deleting the message is a
# stronger guarantee than authorizing it.
diff --git a/packages/meshbay-common/src/meshbay_common/chatbox.py b/packages/meshbay-common/src/meshbay_common/chatbox.py
index 740e829..f0534fa 100644
--- a/packages/meshbay-common/src/meshbay_common/chatbox.py
+++ b/packages/meshbay-common/src/meshbay_common/chatbox.py
@@ -1,7 +1,7 @@
"""
Chat message encryption and sender authentication.
-Design A of `docs/chat-sender-keys.md`, decided 2026-09-07. What it is, and what
+Design A of `docs/MESHBAY_DESIGN.md` §4.5, decided 2026-09-07. What it is, and what
it deliberately is not, in the order the decisions were made:
**Not a ratchet, and not sender keys.** With per-sender chains distributed under
diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py
index cfa8dd6..3a598d0 100644
--- a/packages/meshbay-common/src/meshbay_common/device.py
+++ b/packages/meshbay-common/src/meshbay_common/device.py
@@ -27,7 +27,7 @@ Fields are length-prefixed and domain-separated, per L4 — the same rule as
of the connection carrying the message, so neither signature can be lifted onto
another connection, and `node_pk` binds an authorization to one node.
-See `docs/desktop-client-v1.md` §4.
+See `docs/MESHBAY_DESIGN.md` §3.3.
"""
from __future__ import annotations
diff --git a/packages/meshbay-common/src/meshbay_common/join.py b/packages/meshbay-common/src/meshbay_common/join.py
index 6ee543f..84ea18a 100644
--- a/packages/meshbay-common/src/meshbay_common/join.py
+++ b/packages/meshbay-common/src/meshbay_common/join.py
@@ -11,7 +11,7 @@ from the hub and wrap the group key for whatever came back. The hub is the key
directory, so a hub answering with its own key was handed the GEK by an honest
inviter following the protocol exactly. The key now comes from the peer over an
authenticated channel and is bound to an identity by a one-time pairing code the
-hub never sees. See `docs/invite-pairing-v1.md`.
+hub never sees. See `docs/MESHBAY_DESIGN.md` §3.4.
Fields are length-prefixed and domain-separated, per L4 — the same rule as
`handshake.py` and `adminop.py`. `nonce_node` is the handshake nonce the node just
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index c3cb099..c444711 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -69,11 +69,11 @@ class MNP:
# never once invoked. A live handler on both transports, plaintext media,
# and no client: removed rather than repaired.
#
- # Not a Double Ratchet message, and never was — `first-review.md` C1
- # rejected exactly that for groups. Since MNP 2.0 it is AES-256-GCM under a
+ # Not a Double Ratchet message, and never was — finding C1
+ # (`docs/MESHBAY_DESIGN.md` §13.1) rejected exactly that for groups. Since MNP 2.0 it is AES-256-GCM under a
# per-device subkey of the group's chat epoch key, signed over the
# ciphertext with the sending device's pinned Ed25519 key. There is no
- # plaintext form on the wire (`chatbox.py`, docs/chat-sender-keys.md);
+ # plaintext form on the wire (`chatbox.py`, docs/MESHBAY_DESIGN.md §4.5);
# `format` distinguishes a *stored* pre-2.0 row, which is still served.
CHAT_MESSAGE = "chat_msg" # one chat message, sealed and signed
CHAT_ATTACHMENT = "chat_attach" # attachment metadata
@@ -136,7 +136,7 @@ class MNP:
GEK_BUNDLE_RESP = "gek_bundle_resp" # node → client: wrapped GEK bundle
KEYPAIR_BUNDLE_STORE = "keypair_bundle_store" # client → node: store encrypted keypair bundle
# optional `bundle_enc_recovery` (MNP 0.14): a second copy wrapped under the
- # account's recovery key (docs/auth-confirm.md §4.3)
+ # account's recovery key (docs/MESHBAY_DESIGN.md §3.6)
KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch" # client → node: request own keypair bundle
KEYPAIR_BUNDLE_RESP = "keypair_bundle_resp" # node → client: encrypted keypair bundle
# carries `bundle_enc_recovery` too when the node has one stored
@@ -178,9 +178,9 @@ class MNP:
TMDB_OVERRIDE_ACK = "tmdb_override_ack"
TMDB_REMATCH = "tmdb_rematch" # operator → node: drop one file's match
TMDB_REMATCH_ACK = "tmdb_rematch_ack"
- # Music app (docs/musicbay.md). Contact is derived from the owner's hub
- # email at login — no config/ack pair needed. Only the per-group toggle
- # remains.
+ # Music app (docs/MESHBAY_DESIGN.md §9.8). Contact is derived from the
+ # owner's hub email at login — no config/ack pair needed. Only the
+ # per-group toggle remains.
MUSICBRAINZ_ENABLED = "musicbrainz_enabled" # operator → node: enable/disable
MUSICBRAINZ_ENABLED_ACK = "musicbrainz_enabled_ack" # node → this group: new enabled state
MUSIC_META_REQ = "music_meta_req" # client → node: metadata for a path
@@ -268,7 +268,7 @@ class MNP:
# below, which is the operator's view of the whole node: this is scoped to
# one group and answers any member of it, because the point is that a member
# verifies another member's device *for themselves* rather than trusting the
- # node's `sender_id` (Tier 2, docs/desktop-client-v1.md §4.8).
+ # node's `sender_id` (Tier 2, docs/MESHBAY_DESIGN.md §3.3).
GROUP_ROSTER_REQ = "group_roster_req"
GROUP_ROSTER_RESP = "group_roster_resp"
ROSTER_READ = "roster_read" # operator → node: list pinned identities + members
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 67e65f2..83b60f2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -8,7 +8,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import issue_access_token
@@ -89,6 +89,22 @@ class NodeAnnounceRequest(BaseModel):
signature: str | None = None # base64 Ed25519 over the announce message
+# How many distinct node keys one account may announce.
+#
+# M8 closed the half of this that was about *whose* key it is: the announcer now
+# proves possession. What it did not close is *how many*. Each new key is a row
+# in `nodes` plus a row in the IP log, and the IP log is kept for a year — so an
+# account in a loop writes a year of storage on somebody else's disk, having paid
+# only for the signatures.
+#
+# Ten is past what the feature is for. A node is a machine left running: a
+# desktop, a laptop, a box in a cupboard, a second home. Someone who genuinely
+# reaches it deletes one, which is a thing the operator surface already does —
+# and an account that wants an eleventh *identity* rather than an eleventh
+# machine is the case this refuses.
+MAX_NODES_PER_ACCOUNT = 10
+
+
@router.post("/announce", status_code=201)
async def announce_node(
body: NodeAnnounceRequest,
@@ -146,6 +162,21 @@ async def announce_node(
await db.commit()
return {"node_id": node.id}
+ # Counted only where a row is actually added: re-announcing a key this
+ # account already holds takes the branch above and must keep working at the
+ # ceiling, or a node that has reached it can never refresh its address again.
+ held = (await db.execute(
+ select(func.count()).select_from(Node)
+ .where(Node.user_id == current_user.id))).scalar() or 0
+ if held >= MAX_NODES_PER_ACCOUNT:
+ db.add(IPLog(user_id=current_user.id, event="node_announce_refused",
+ ip_address=seen_from, detail=f"{held} nodes"))
+ await db.commit()
+ raise HTTPException(
+ status_code=409,
+ detail=f"This account already has {held} nodes, which is the limit of "
+ f"{MAX_NODES_PER_ACCOUNT}. Remove one you no longer run.")
+
node = Node(
user_id=current_user.id,
pk_node=body.pk_node,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 7b2fc8c..0394f53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -107,7 +107,7 @@ class RegisterRequest(BaseModel):
email: str
password: str | None = None # deprecated — legacy native clients
auth_key: str | None = None # PBKDF2-derived, new clients
- # Client-generated account recovery key (docs/auth-confirm.md §4.4). Pure
+ # Client-generated account recovery key (docs/MESHBAY_DESIGN.md §3.6). Pure
# pass-through: appended to the verification e-mail so the user's mailbox
# backs it up, then dropped. Never written to any table, never logged.
recovery_key: str | None = None
@@ -901,7 +901,7 @@ async def verify_email_change(
# ── Passphrase change (Flow A) ──────────────────────────────────────────────
#
-# docs/auth-confirm.md §3. The passphrase derives two independent values on the
+# docs/MESHBAY_DESIGN.md §3.6. The passphrase derives two independent values on the
# client: auth_key (verified here) and bundle_key (AES-GCM key for the per-node
# identity bundles, which live on nodes and never on the hub). The client
# re-wraps those bundles from the old bundle_key to the new one on every
@@ -1023,7 +1023,7 @@ async def change_password(
# ── Passphrase reset (Flow B) ──────────────────────────────────────────────
#
-# docs/auth-confirm.md §4.2. An e-mail code re-opens hub login for someone who
+# docs/MESHBAY_DESIGN.md §3.6. An e-mail code re-opens hub login for someone who
# has lost their passphrase. It recovers no group content — that needs the
# recovery key, which the client applies on its own after the reset.
# reset-request never reveals whether an account exists.
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 876583e..f2212de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -75,7 +75,7 @@ class CaptchaConfig:
# Set this when that check is turned off in the reCAPTCHA console, which is
# what the desktop client needs: its page is served from `app://meshbay`,
# so the hostname Google sees is not the hub's and never can be. See
- # docs/captcha.md §6.
+ # docs/MESHBAY_DESIGN.md §7.7.
allowed_hosts: list[str] = field(default_factory=list)
# A solve from a page Google cannot attribute to a domain reports an empty
# hostname — `app://meshbay` does, measured live, and so does any other
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index f1fff41..62e4a11 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -201,7 +201,7 @@ class UserDevice(Base):
directory *others* read from, where a substituted key was handed the
GEK by an honest member.
* **It is not a node identity key.** Those are generated per node, pinned
- there, and never leave that relationship (`docs/per-node-identity-v1.md`).
+ there, and never leave that relationship (`docs/MESHBAY_DESIGN.md` §3.2).
A device holds one of these *plus* a different key per node, so nothing
here correlates a person across operators.
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index aa6b74e..126ed45 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -315,7 +315,7 @@ def send_verification_code(to: str, code: str, recovery_key: str | None = None)
"""
Registration verification e-mail. When `recovery_key` is given it is
appended to the body so the recipient's mailbox becomes the backup for it
- (docs/auth-confirm.md §4.4).
+ (docs/MESHBAY_DESIGN.md §3.6).
`recovery_key` is a **pass-through**: it is generated on the client, never
stored anywhere on the hub, and never logged — only whether one was present.
@@ -374,7 +374,7 @@ def send_email_change_code(to: str, code: str) -> None:
def send_password_reset_code(to: str, code: str) -> None:
"""
- Passphrase-reset code (docs/auth-confirm.md §4.2). This only re-opens hub
+ Passphrase-reset code (docs/MESHBAY_DESIGN.md §3.6). This only re-opens hub
login; it recovers no group content — that needs the recovery key.
"""
msg = EmailMessage()
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 92dcb28..fbfb942 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -10,7 +10,7 @@ import * as downloads from './downloads.js';
import { Icon } from './icon.js';
import { formatSize } from './file-utils.js';
import {
- HUB, navigate, session, getCachedGroupIndex,
+ HUB, navigate, session, purgeGroupIndexCache,
_storeBundleKey, _loadBundleKey, _clearKeyDB,
loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch,
refreshAccessToken, logoutOnHub,
@@ -1252,7 +1252,23 @@ const trayLabels = () => ({
// Catalogues are fetched, so the first render waits for one: mounting earlier
// would paint the interface in English and then swap every string. initLocale()
// falls back to English rather than rejecting, so this cannot strand the page.
+// One sweep, once per browser, to remove what the cross-group search of 2026-08
+// left behind: a cleartext copy of every group's file listing that nothing has
+// read since, and that no sign-out removed. Guarded by a flag so it costs one
+// transaction ever rather than one per load; a browser that refuses storage
+// simply does it again, which is harmless.
+const PURGED_KEY = 'meshbay.indexcache.purged';
+const purgeOnce = () => {
+ try {
+ if (localStorage.getItem(PURGED_KEY)) return;
+ } catch { /* no storage: purge anyway, it is idempotent */ }
+ purgeGroupIndexCache().then(() => {
+ try { localStorage.setItem(PURGED_KEY, '1'); } catch { /* nothing to remember with */ }
+ });
+};
+
const mount = () => {
+ purgeOnce();
render(html`<${App} />`, document.getElementById('app'));
// Get the download worker registered and this page under its control now,
// rather than inside the first click on Download. On Firefox and Safari it is
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 461bd57..32f2314 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -70,7 +70,7 @@ const APPS = [
Component: MusicApp, Settings: MusicSettings },
{ key: 'photo', icon: 'image', labelKey: 'group.tab_photos',
Component: PhotosApp, Settings: PhotoSettings },
- // The reference implementation (docs/refactor-groups.md §4.1). `dev` keeps
+ // The reference implementation (docs/MESHBAY_DESIGN.md §9.4). `dev` keeps
// it out of an operator's way; everything else about it is an ordinary
// entry, which is the point.
{ key: 'helloworld', icon: 'chat', labelKey: 'group.tab_helloworld',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 04a00af..8958d44 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -301,7 +301,7 @@ export function RegisterPage() {
setLoading(true);
try {
if (window.MeshBayKeys) {
- // The account recovery key (docs/auth-confirm.md §4.3/§4.4): generated
+ // The account recovery key (docs/MESHBAY_DESIGN.md §3.6): generated
// here, shown once on the next screen. When the user leaves "e-mail it"
// checked, the mnemonic goes in the register body so the hub appends it
// to the verification e-mail (and stores it nowhere); otherwise it is
@@ -511,7 +511,7 @@ export function RegisterPage() {
}
-// ── Passphrase reset — Flow B (docs/auth-confirm.md §4) ─────────────────────
+// ── Passphrase reset — Flow B (docs/MESHBAY_DESIGN.md §3.6) ─────────────────
//
// An e-mail code restores hub login. A recovery key, if the user still has one,
// restores the per-node identities in the same step: the fan-out reads each
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
index b732734..decf916 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -507,11 +507,11 @@ async function downloadEntry(transfers, transport, gek, entry) {
* only alternative is to build the whole thing in memory — so that path is
* offered but says what it costs first.
*
- * Lifted out of files-app.js (docs/photos.md §3) so photos-app.js's own
+ * Lifted out of files-app.js (docs/MESHBAY_DESIGN.md §9.9) so photos-app.js's own
* "zip this album" button calls the same implementation rather than a
* second one — nothing here is Files-specific once `entries`/`transport`/
* `gek`/`setError` are passed in, the same shared-context shape every app
- * already receives (apps.md §2).
+ * already receives (docs/MESHBAY_DESIGN.md §9.2).
*/
async function downloadDirectory(transfers, transport, gek, entries, dir, { setError }) {
if (!transport || !transport.connected) return;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 7629459..3f4212e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -314,7 +314,7 @@ function FilesPanel({
}
}, [currentPath]);
- // The implementation lives in file-utils.js (docs/photos.md §3) so
+ // The implementation lives in file-utils.js (docs/MESHBAY_DESIGN.md §9.9) so
// photos-app.js's own "zip this album" button can call the same code
// rather than a second one.
const downloadDirectory = useCallback(async (dir) => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js
index f109da7..f0ace6a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js
@@ -8,7 +8,7 @@ import { sourceLabel } from './source-merge.js';
* Group names are unique only per owner account (the hub enforces that), so the
* `@handle` is what tells two groups called "photos" apart. `owner` is the
* owner's username for a local group, or the source hub for a federated one
- * (decision 5 in ~/next/groupnames.md); the caller decides which.
+ * (docs/MESHBAY_DESIGN.md §7.3); the caller decides which.
*
* `inline` renders "name@owner" on one line, for places that cannot take a
* block — a badge, a `confirm()` string built elsewhere.
@@ -29,7 +29,7 @@ export function GroupName({ name, owner, inline = false }) {
* The Search view merges a file several groups share into one entry, so the
* badge under a card cannot always name a group. One source keeps naming it
* and keeps linking to it; more than one becomes a count, and *which* one was
- * picked is deliberately not shown (docs/refactoring-search.md §5.5).
+ * picked is deliberately not shown (docs/MESHBAY_DESIGN.md §9.11).
*
* `entries` is the whole unit — every episode of a show, every track of an
* album — not the entry the card was drawn from; `sourceLabel` explains why.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 205fce2..26519da 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -6,7 +6,7 @@ import { Icon } from './icon.js';
import { transfers } from './transfers.js';
import { downloadEntry } from './file-utils.js';
import {
- HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken,
+ HUB, session, hubFetch, ensureFreshToken,
_loadBundleKey, _loadRecoveryKey, _storeBundleKey,
} from './hub-client.js';
import { APPS, visibleApps } from './apps.js';
@@ -154,10 +154,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// that). Null until the handshake ack arrives.
const [scanSettings, setScanSettings] = useState(null);
// TMDB on/off + whether a custom token is set, node-wide (not per-group) —
- // docs/mediacenter.md §5.5. Null until the handshake ack arrives.
+ // docs/MESHBAY_DESIGN.md §9.7. Null until the handshake ack arrives.
const [tmdbConfig, setTmdbConfig] = useState(null);
// Which folders each app works over. One shape for all of them — a list,
- // always, even where an app only wants one (docs/refactor-groups.md §1.6):
+ // always, even where an app only wants one (docs/MESHBAY_DESIGN.md §9.3):
// Videos and Music were single values, which meant a library spread over two
// drives could not be described at all. Empty means nothing configured yet,
// which every app reads as "show nothing", never "the whole group index".
@@ -171,7 +171,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// Whether members' cross-group Search lists this group. Not an app setting:
// it is about the group as a whole, and it hides nothing from this page.
const [searchListed, setSearchListed] = useState(true);
- // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2.
+ // MusicBrainz on/off (per-group) — docs/MESHBAY_DESIGN.md §9.8.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
// `op` rides through to the shell — 'replace', 'next' or 'append'
// (docs/playlists.md §9.2). It has to be named here: a wrapper that takes
@@ -265,10 +265,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setEntries(fresh);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
- cacheGroupIndex(groupId, group ? group.name : groupId,
- group ? group.owner_username : null, fresh,
- { video: appDirs('video'), music: appDirs('music'),
- photo: appDirs('photo') });
}, [groupId, group, appDirs]);
// additions/deletions/updates (daemon.py _broadcast_index_change, once
@@ -296,10 +292,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const keptIds = new Set(updated.map((e) => e.id));
const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id));
const fresh = updated.concat(additions);
- cacheGroupIndex(groupId, group ? group.name : groupId,
- group ? group.owner_username : null, fresh,
- { video: appDirs('video'), music: appDirs('music'),
- photo: appDirs('photo') });
return fresh;
});
}, [groupId, group, appDirs]);
@@ -320,7 +312,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setDeviceReady(false);
gekRef.current = null;
if (!session.bundleKey) session.bundleKey = await _loadBundleKey();
- // Persisted (docs/auth-confirm.md §4.3) so a group joined in a later
+ // Persisted (docs/MESHBAY_DESIGN.md §3.6) so a group joined in a later
// session still leaves a recovery-wrapped identity copy on its node.
if (!session.recoveryKey) session.recoveryKey = await _loadRecoveryKey();
if (!session.bundleKey && window.MeshBayKeys) {
@@ -669,7 +661,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// the only unambiguous answer once a group can have several writable roots.
// Chat has no folder to browse, so it needs one picked for it, and this is
// the same rule the node applies when a client names no root at all. It
- // becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7).
+ // becomes an operator-chosen directory in phase 2 (docs/MESHBAY_DESIGN.md §9.6).
//
const writableRoots = useMemo(
() => nodeRoots.filter((r) => r.writable && r.available !== false),
@@ -735,7 +727,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// rather than written out. Naming them here would mean adding an app
// required editing this file, which is the one thing the plugin
// architecture is supposed to have removed — and the reference app
- // (docs/refactor-groups.md §4.1) is what made the difference visible.
+ // (docs/MESHBAY_DESIGN.md §9.4) is what made the difference visible.
const perAppDirectories = useMemo(() => {
const out = {};
for (const app of APPS) out[`${app.key}Directories`] = appDirs(app.key);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js
index 9058199..be26fbc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js
@@ -11,9 +11,10 @@ import { FolderPickerField } from './folder-tree.js';
* app's registry key by the page, and `ops.set_app_directories` stores the row
* under that name without knowing what it is.
*
- * It takes the shared props and no others (`docs/apps.md` §3b), which is what
- * `test_app_settings_plugin.py` checks of every pane — including this one, so
- * the reference implementation is held to the contract it demonstrates.
+ * It takes the shared props and no others (`docs/MESHBAY_DESIGN.md` §9.2),
+ * which is what `test_app_settings_plugin.py` checks of every pane — including
+ * this one, so the reference implementation is held to the contract it
+ * demonstrates.
*/
function HelloWorldSettings({ roots, dirs, settings, saveDirectories }) {
const { busy, msg, run } = useSaver();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js
index 01b4ed4..98ca8fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js
@@ -7,7 +7,7 @@ import { formatSize } from './file-utils.js';
* The smallest application this platform can host, and the proof that adding
* one costs nothing outside its own two files.
*
- * Everything else in `docs/refactor-groups.md` §3 is asserted by tests that
+ * Everything else in `docs/MESHBAY_DESIGN.md` §9.4 is asserted by tests that
* read source. This is the other kind of evidence: an app nobody wrote a line
* of plumbing for, that stores directories, appears as a tab, and lists files —
* because the registry entry beside it is genuinely all there is.
@@ -18,7 +18,7 @@ import { formatSize } from './file-utils.js';
* put a toy app in everybody's group; deleting the app would leave the claim
* resting entirely on tests that read text.
*
- * It takes the standard props — see `docs/apps.md` §2 — and reads
+ * It takes the standard props — see `docs/MESHBAY_DESIGN.md` §9.2 — and reads
* `helloworldDirectories`, which nothing on the node knows about by name:
* `ops.set_app_directories` keys the row by whatever the app is called.
*/
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index 81185a5..ba91f00 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -81,42 +81,26 @@ function openDB() {
});
}
-async function cacheGroupIndex(groupId, groupName, groupOwner, entries, roots) {
- try {
- const db = await openDB();
- const tx = db.transaction(IDB_STORE, 'readwrite');
- tx.objectStore(IDB_STORE).put({
- groupId, groupName, groupOwner, entries, roots: roots || {},
- cachedAt: Date.now(),
- });
- await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; });
- db.close();
- } catch { /* best-effort */ }
-}
-
-async function getCachedGroupIndex(groupId) {
- try {
- const db = await openDB();
- const tx = db.transaction(IDB_STORE, 'readonly');
- const req = tx.objectStore(IDB_STORE).get(groupId);
- const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
- db.close();
- return result || null;
- } catch { return null; }
-}
-
-async function getAllCachedIndexes() {
- try {
- const db = await openDB();
- const tx = db.transaction(IDB_STORE, 'readonly');
- const req = tx.objectStore(IDB_STORE).getAll();
- const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
- db.close();
- return result || [];
- } catch { return []; }
-}
-
-async function clearAllCachedIndexes() {
+// `group_indexes` held a decrypted copy of every group's index — each file's
+// name, path, size, hash and uploader — written on every index and every delta,
+// and read by the cross-group search of the time, which searched those records
+// instead of dialling anything.
+//
+// Search has dialled the nodes since 2026-08-28. The reader went with that
+// change and the writers stayed, so for weeks the browser kept building a
+// cleartext file listing that nothing consulted and no sign-out removed: the key
+// database is a different one. It is **L7** — code nothing calls does not sit
+// still, it accumulates.
+//
+// Showing a group's files while its node is unreachable was the only use left
+// for such a cache, and it is not wanted: a listing you cannot open is worse
+// than an honest absence.
+//
+// The store itself is left in the schema. Dropping it means a version bump, and
+// a version bump means an upgrade another tab can block — which would take
+// playlists down with it, since they share this database. Emptying it costs
+// nothing and leaves nothing behind.
+async function purgeGroupIndexCache() {
try {
const db = await openDB();
const tx = db.transaction(IDB_STORE, 'readwrite');
@@ -136,7 +120,7 @@ async function clearAllCachedIndexes() {
// so importing modules can update either field without this module handing
// out a rebindable export.
//
-// `recoveryKey` (docs/auth-confirm.md §4.3) is the AES key that wraps the
+// `recoveryKey` (docs/MESHBAY_DESIGN.md §3.6) is the AES key that wraps the
// *recovery* copy of an identity bundle. In-memory only, and set only when the
// user has just generated or entered the recovery secret (registration, or the
// Flow B screen) — it cannot be re-derived from the passphrase.
@@ -169,10 +153,11 @@ async function _loadKey(slot) {
return val || null;
} catch { return null; }
}
-// 'bk' = passphrase-derived bundle key; 'rk' = recovery key (docs/auth-confirm.md
-// §4.3). Persisting 'rk' is what lets a group joined in a *later* session still
-// get a recovery-wrapped identity copy, instead of only groups joined in the
-// unbroken session that generated it. Cleared with everything else on sign-out.
+// 'bk' = passphrase-derived bundle key; 'rk' = recovery key
+// (docs/MESHBAY_DESIGN.md §3.6). Persisting 'rk' is what lets a group joined
+// in a *later* session still get a recovery-wrapped identity copy, instead of
+// only groups joined in the unbroken session that generated it. Cleared with
+// everything else on sign-out.
const _storeBundleKey = (key) => _storeKey('bk', key);
const _loadBundleKey = () => _loadKey('bk');
const _storeRecoveryKey = (key) => _storeKey('rk', key);
@@ -377,7 +362,7 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) {
export {
HUB, navigate, session,
openDB, IDB_PLAYLISTS,
- cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes,
+ purgeGroupIndexCache,
_storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB,
loadAuth, saveAuth, setAuth, setAuthChangeListener,
tokenLifeLeft, refreshAccessToken, ensureFreshToken, logoutOnHub, hubFetch,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index edfa109..879f56f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -191,7 +191,7 @@ async function deriveBundleKeys(password, username) {
// ── Account recovery key ─────────────────────────────────────────────────────
//
-// docs/auth-confirm.md §4.3. A full-entropy secret the user keeps outside the
+// docs/MESHBAY_DESIGN.md §3.6. A full-entropy secret the user keeps outside the
// passphrase — in their password manager, or (step 3) e-mailed to them. It
// wraps a *second* copy of every per-node identity bundle, so a forgotten
// passphrase does not strand the account's group identities.
@@ -323,7 +323,7 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha
const payload = { username, email, auth_key: authKey };
// The recovery mnemonic, when the user opted to have it e-mailed: the hub
// appends it to the verification e-mail and stores it nowhere
- // (docs/auth-confirm.md §4.4). Omitted when they chose to save it themselves.
+ // (docs/MESHBAY_DESIGN.md §3.6). Omitted when they chose to save it themselves.
if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic;
// reCAPTCHA response, when the hub has a captcha configured. The widget lives
// in RegisterPage (auth-page.js); this function just forwards its token. A
@@ -347,7 +347,7 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha
* bundle goes to that node and nowhere else, and is what any other browser
* fetches to become the same person there. When `recoveryKey` is supplied a
* second copy wrapped under it rides along, so a forgotten passphrase does not
- * strand this identity (docs/auth-confirm.md §4.3).
+ * strand this identity (docs/MESHBAY_DESIGN.md §3.6).
*/
async function generateNodeIdentity(bundleKey, recoveryKey) {
const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
@@ -465,7 +465,7 @@ async function _bundleKeyPairFields(password, username) {
window.MeshBayKeys = {
registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
- // Exposed for the passphrase change (docs/auth-confirm.md §3): re-wrapping a
+ // Exposed for the passphrase change (docs/MESHBAY_DESIGN.md §3.6): re-wrapping a
// node's identity bundle needs the old key (a {v2,v1} pair, since an old
// bundle may be v1) to read it and the new v2 key to write it back.
deriveEncryptionKey, deriveEncryptionKeyV1,
@@ -473,6 +473,6 @@ window.MeshBayKeys = {
// `session.bundleKey` uses this, so `v2hkdf` is never the field one sign-in
// path forgot (docs/playlists.md §3.4).
deriveBundleKeys, bundleKeyPairFields: _bundleKeyPairFields,
- // Account recovery key (docs/auth-confirm.md §4.3).
+ // Account recovery key (docs/MESHBAY_DESIGN.md §3.6).
generateRecoveryKey, deriveRecoveryKey,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
index 8ce2ab6..a9a3c95 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
@@ -8,7 +8,7 @@ import { FolderPickerField } from './folder-tree.js';
*
* Same shape as Videos, minus a credential: MusicBrainz's read endpoints need
* no API key, only a descriptive User-Agent, and that is one operator identity
- * held node-wide rather than a per-group setting (docs/musicbay.md §3.2).
+ * held node-wide rather than a per-group setting (docs/MESHBAY_DESIGN.md §9.8).
*
* Several folders, for the same reason Videos has several: a music library
* that lives on two drives had no way to say so.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index baa3520..e0308cb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -15,13 +15,13 @@ import * as P from './playlists.js';
//
// An album-grid (MusicBrainz-enriched, when a track has no usable embedded
// cover) or flat (tag/filename-only) browser for a group's audio files, per
-// docs/musicbay.md. Grouping is by `artist`/`album` -- already resolved at
-// index time from embedded tags, falling back to filename/folder parsing
-// (indexer/enrich_audio.py) -- never guessed here.
+// docs/MESHBAY_DESIGN.md §9.8. Grouping is by `artist`/`album` -- already
+// resolved at index time from embedded tags, falling back to filename/folder
+// parsing (indexer/enrich_audio.py) -- never guessed here.
//
// Unlike Videos, MusicBrainz is looked up only when a track has no embedded
-// cover art at all (musicbay.md section 2.1's tiered trust: tags first,
-// filename parsing second, MusicBrainz last) -- most of a real, well-ripped
+// cover art at all (docs/MESHBAY_DESIGN.md §9.8's order of trust: tags
+// first, filename parsing second, MusicBrainz last) -- most of a real, well-ripped
// library already carries good artist/album text and often its own cover, so
// this avoids a network round trip most tiles never need. Playback never
// touches this file: clicking a track calls the `onPlayQueue` prop the shell
@@ -52,7 +52,7 @@ function foldKey(s) {
}
// Same shape as video-app.js's underVideoRoot: an unset root means "show
-// nothing" (docs/musicbay.md's amended §2.1 — the node itself runs no
+// nothing" (docs/MESHBAY_DESIGN.md §9.8 — the node itself runs no
// tag/cover enrichment for this group before a root is chosen either,
// daemon.py's _enrich_new_audio_entries), not "the whole shared tree" —
// falling back to that would just show files nothing has enriched.
@@ -212,17 +212,16 @@ function useMusicMeta(transportRef, fileId, active) {
}
// A drawn CD standing in for a cover nothing supplied one for -- most tiles
-// in a real, older/well-ripped library land here (musicbay.md's own
-// measurement: ~11% embedded art, ~26% once sibling image files are counted
-// too), so this is the *default* look of the grid, not a rare fallback, and
-// needed to read as a deliberate piece of art rather than a broken image.
-// A flat single-color icon (the first version of this) looked exactly like
-// "missing", not "no cover" -- an actual disc, with the iridescent sheen a
-// real CD's data side has, reads as intentional at a glance. Genuinely
-// unique gradient ids: a `<radialGradient>` id is a plain DOM id, and a grid
-// full of these renders many instances at once -- reusing one literal id
-// would leave every disc after the first pointing at whichever def the
-// browser resolves first.
+// in a real, older/well-ripped library land here (measured: ~11% embedded
+// art, ~26% once sibling image files are counted too), so this is the
+// *default* look of the grid, not a rare fallback, and needed to read as a
+// deliberate piece of art rather than a broken image. A flat single-color icon
+// (the first version of this) looked exactly like "missing", not "no cover" --
+// an actual disc, with the iridescent sheen a real CD's data side has, reads
+// as intentional at a glance. Genuinely unique gradient ids: a
+// `<radialGradient>` id is a plain DOM id, and a grid full of these renders
+// many instances at once -- reusing one literal id would leave every disc
+// after the first pointing at whichever def the browser resolves first.
let _discIdSeq = 0;
function DiscPlaceholder({ cls }) {
@@ -517,8 +516,8 @@ function FlatArtistFolder({ artist, onPlayQueue, onMenu }) {
${open && html`
<div class="music-flat-children">
${/* A real artist folder with no album layer at all is common here
- -- a pile of loose singles, not one release (musicbay.md
- section 2.1's "flat per-artist folder" case). Nesting them
+ -- a pile of loose singles, not one release (a flat
+ per-artist folder, docs/MESHBAY_DESIGN.md §9.8). Nesting them
one more level behind their own always-empty "Unknown album"
row was exactly the friction reported live: an extra,
pointless expand before reaching a track that's playable
@@ -748,7 +747,7 @@ function MusicApp({
}
// foldKey rides along for the Search page's merge unit keys
-// (docs/refactoring-search.md §5.2). An album's *display* strings are the
+// (docs/MESHBAY_DESIGN.md §9.11). An album's *display* strings are the
// first-seen spelling, and which group is seen first is the order its index
// happened to arrive in — so keying a unit on them would let the chosen source
// change between page loads. The folded key is the one grouping actually used,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
index c3a2868..5f2e33c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -8,7 +8,7 @@ import { useStickyBand } from './sticky.js';
import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
/**
- * The Music app's persistent player bar (docs/musicbay.md §2.3, §7.2).
+ * The Music app's persistent player bar (docs/MESHBAY_DESIGN.md §9.8).
*
* Owned and rendered by app.js — the router's parent — so playback survives
* navigating between groups, search, and other pages. Both group-page.js
@@ -23,11 +23,12 @@ import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
* track is a few megabytes, so it is downloaded and decrypted once through
* the same chunk pipeline Files already uses (file-utils.js's
* pipelinedDownload), then played from a blob URL — the deliberate
- * simplification recorded in musicbay.md §2.2. WMA and Musepack are the one
- * exception (`NEEDS_TRANSCODE_RE` below): neither decodes in a browser's
- * <audio> element at all, tagged correctly or not, so those two go through
- * `transport.requestAudioTranscode` first — a node-side, cached-after-once
- * AAC/M4A conversion — before the same download/blob path runs.
+ * simplification recorded in docs/MESHBAY_DESIGN.md §9.8. WMA and Musepack are
+ * the one exception (`NEEDS_TRANSCODE_RE` below): neither decodes in a
+ * browser's <audio> element at all, tagged correctly or not, so those two go
+ * through `transport.requestAudioTranscode` first — a node-side,
+ * cached-after-once AAC/M4A conversion — before the same download/blob path
+ * runs.
*/
const MIME_BY_EXT = {
@@ -35,9 +36,12 @@ const MIME_BY_EXT = {
wav: 'audio/wav', aac: 'audio/aac', m4a: 'audio/mp4',
};
-// Kept in sync with the node's BROWSER_INCOMPATIBLE_AUDIO_EXTS
-// (webrtc_server.py) — both name the same two formats no mainstream
-// browser's <audio> element decodes natively.
+// The same two formats as the node's BROWSER_INCOMPATIBLE_AUDIO_EXTS
+// (webrtc_server.py), which is the one that decides: the node refuses a
+// transcode request for anything else. This is here so the player does not
+// ask for one it knows will be refused — not because it enforces the rule.
+// It used to be the only thing that did, and a member's own message never
+// passed through it.
const NEEDS_TRANSCODE_RE = /\.(wma|mpc)$/i;
function guessMime(name) {
@@ -271,7 +275,7 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue
// Screen Wake Lock, opt-in only (Settings → music_keep_screen_on) and only
// while a track is actually playing — off by default because the ordinary
// expectation, matching Spotify/Deezer, is that the phone locks on its own
- // idle timer while listening (docs/musicbay.md §2.2). Unlike the video
+ // idle timer while listening (docs/MESHBAY_DESIGN.md §9.8). Unlike the video
// player's unconditional lock, this must not fight that default for
// everyone who never asked for it; it exists for whoever explicitly wants
// to trade battery for riding out the WebRTC screen-lock reconnect gap
@@ -368,7 +372,8 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue
}, [getConnection, evictOldBlobs]);
// Silently warms the cache for the next tracks so pressing "next" doesn't
- // visibly wait (musicbay.md §2.2) — best-effort, never surfaces an error.
+ // visibly wait (docs/MESHBAY_DESIGN.md §9.8) — best-effort, never surfaces
+ // an error.
//
// More than one: a screen lock can cost the transport several minutes (see
// the WebRTC auto-reconnect in transport.js — this is the other half of
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
index 8656fa5..0407e67 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
@@ -6,7 +6,7 @@ import { FolderPickerField } from './folder-tree.js';
/**
* The Photos app's operator settings: folders, and nothing else.
*
- * Photos was always several folders (docs/photos.md §2.1) — a photo library
+ * Photos was always several folders (docs/MESHBAY_DESIGN.md §9.9) — a photo library
* is routinely scattered, with no single natural root — so this pane is what
* the other two grew into rather than the exception it used to be. No
* third-party service: EXIF is read locally on the node, and nothing about a
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index 2c4356c..261f2af 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -12,7 +12,7 @@ import { SourceTag } from './group-name.js';
// ── Photos ───────────────────────────────────────────────────────────────────
//
-// docs/photos.md. Unlike Videos/Music: several root folders per group
+// docs/MESHBAY_DESIGN.md §9.9. Unlike Videos/Music: several root folders per group
// (photoRoots is a list, §2.1), one album-grid view with no mode toggle and
// no third-party matching step (§2.3), and per-photo info read from the
// file's own EXIF at index time rather than fetched live. Every directory
@@ -383,7 +383,7 @@ function PhotosApp({
// groupPhotoAlbums is exported for the Search page, which needs the album a
// photo belongs to in order to merge duplicate sources per album rather than
-// per file (docs/refactoring-search.md §5.2). It calls this one, never a copy:
+// per file (docs/MESHBAY_DESIGN.md §9.11). It calls this one, never a copy:
// a second implementation of the album key would keep agreeing with this one
// right up until one of them changed.
export { PhotosApp, groupPhotoAlbums };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 07e5b6f..cfa4d87 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -2,8 +2,9 @@
* What differs between running in a browser and running as an installed app.
*
* The interface is the same code either way — that is the whole reason Electron
- * was chosen over a shell that replaces the engine (docs/desktop-client-v1.md
- * §2). What genuinely differs is small and lives here:
+ * was chosen over a shell that replaces the engine
+ * (docs/MESHBAY_DESIGN.md §8.2). What genuinely differs is small and lives
+ * here:
*
* · **where the hub is.** Served from the hub, it is the current origin. Ship
* the interface in a package and it becomes a configured URL, because the
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index 4d452e1..a19b543 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -83,7 +83,7 @@ export function ProfilePage({ user, onLogout }) {
setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0);
}, []);
- // ── Passphrase change (docs/auth-confirm.md §3) ─────────────────────────
+ // ── Passphrase change (docs/MESHBAY_DESIGN.md §3.6) ─────────────────────
const [cpOpen, setCpOpen] = useState(false);
const [cpOld, setCpOld] = useState('');
const [cpNew, setCpNew] = useState('');
@@ -168,7 +168,7 @@ export function ProfilePage({ user, onLogout }) {
}
}, [cpOld, cpNew, user]);
- // ── Recovery key backfill (docs/auth-confirm.md §4.3) ───────────────────
+ // ── Recovery key backfill (docs/MESHBAY_DESIGN.md §3.6) ─────────────────
// Enter the recovery key once per browser to add a recovery-wrapped copy of
// your identity to every group — covers groups joined before the key was
// loaded here.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 26d4e4c..0a96237 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -5,7 +5,7 @@ import { t } from './i18n.js';
import { Icon } from './icon.js';
import { canPreview, downloadEntry } from './file-utils.js';
import {
- HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey,
+ HUB, session, hubFetch, ensureFreshToken, _loadBundleKey,
} from './hub-client.js';
import { FilesPanel, FilePreview } from './files-app.js';
import { VideoApp, groupVideoEntries } from './video-app.js';
@@ -207,7 +207,7 @@ async function fetchGroupIndex(groupId, token, bundleKey, username, userId) {
};
// Which of the reader's groups sit on their own node — the tie-breaker
// when the same file is announced by several of them
- // (docs/refactoring-search.md §5.3). Computed by the node from its own
+ // (docs/MESHBAY_DESIGN.md §9.11). Computed by the node from its own
// record of who it belongs to (webrtc_server.py's _is_node_admin), never
// from a hub claim, and deliberately not written to the index cache: it
// describes this connection, not the group's content.
@@ -295,7 +295,6 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onRe
groupName: g.name,
groupOwner: g.owner_username,
});
- cacheGroupIndex(g.id, g.name, g.owner_username, result.entries, result.roots);
// Drawn now, not when this group's neighbours are done. Its index is
// already in hand; holding it back until a group that is not answering
// has finished not answering is ten seconds of blank page for work that
@@ -349,7 +348,7 @@ function cachedDirs(roots, appKey, legacyKey) {
// different people invited to different libraries — arrived here as two
// entries per file, so a film showed as two poster cards and every episode
// twice inside a show. `source-merge.js` folds them on the content hash and
-// resolves one source per *unit*. See docs/refactoring-search.md.
+// resolves one source per *unit*. See docs/MESHBAY_DESIGN.md §9.11.
//
// The units come from video-app.js's own `groupVideoEntries`, never from a
// second copy of its keys here: a copy would keep agreeing with the original
@@ -465,7 +464,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
// A group whose connection failed stops being chosen as a merged entry's
// source, so a unit fails over to another group that has the file
- // (docs/refactoring-search.md §5.4). Without this the merge could make a
+ // (docs/MESHBAY_DESIGN.md §9.11). Without this the merge could make a
// file *less* available than it was before it, which would be a regression
// dressed as a feature.
//
@@ -609,7 +608,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
}, [indexedGroups]);
// How a unit's source is chosen, shared by every merged view
- // (docs/refactoring-search.md §5.3). `isLocal` reads the flag the node itself
+ // (docs/MESHBAY_DESIGN.md §9.11). `isLocal` reads the flag the node itself
// put in the handshake ack — computed from its own record of who it belongs
// to (webrtc_server.py's `_is_node_admin`), never from a hub claim.
const mergeOpts = useMemo(() => ({
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
index 51d99cd..9a52059 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
@@ -19,7 +19,7 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
() => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted])));
const [globalMute, setGlobalMute] = useState(false);
const [defaultTab, setDefaultTab] = useState('chat');
- // Off by default (musicbay.md §2.2): the ordinary expectation, matching
+ // Off by default (docs/MESHBAY_DESIGN.md §9.8): the ordinary expectation, matching
// Spotify/Deezer, is that the phone locks on its own idle timer while
// listening. This is for whoever would rather trade battery for it —
// e.g. to ride out the WebRTC screen-lock reconnect gap without waiting
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js b/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js
index 2c08e06..b6ce0bd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js
@@ -6,7 +6,7 @@
* paths are already one entry. The Search page is what creates duplicates, by
* concatenating N independently keyed indexes into one list — a directory
* shared by two groups then shows every film twice, every episode twice, every
- * track twice. See docs/refactoring-search.md.
+ * track twice. See docs/MESHBAY_DESIGN.md §9.11.
*
* Two rules decide everything here:
*
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 2d07fd1..81ae895 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1793,7 +1793,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
margin: 0;
}
-/* Photos app's root add/remove list (docs/photos.md §2.2) — a set, unlike
+/* Photos app's root add/remove list (docs/MESHBAY_DESIGN.md §9.9) — a set, unlike
the Videos/Music single-value picker above. */
.settings-root-list { list-style: none; margin: 4px 0 8px; padding: 0; }
.settings-root-list-item {
@@ -4031,7 +4031,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.video-flat-season { padding-left: 24px; margin-bottom: 8px; }
.video-flat-season .video-season-header { margin: 6px 0 4px; }
-/* Season picker — docs/mediacenter.md §5.4's per-season overview view.
+/* Season picker — docs/MESHBAY_DESIGN.md §9.7's per-season overview view.
Was a row of pills with `overflow-x: auto`: a show with a dozen seasons hid
most of them behind a horizontal scrollbar, worst on the narrow screens that
can least afford it. One trigger and a menu is one row high whatever the
@@ -4139,7 +4139,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
border-radius: 4px;
}
-/* ── Music app (docs/musicbay.md) ─────────────────────────────────────────
+/* ── Music app (docs/MESHBAY_DESIGN.md §9.8) ──────────────────────────────
Reuses .video-overlay/.video-top-bar/.video-title/.video-close, .tb-btn/
.tb-search, .video-flat-list/.video-flat-row/.video-flat-info/
.video-flat-title/.video-flat-sub/.video-flat-folder/.video-flat-chevron
@@ -4346,7 +4346,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
back rather than compounding with the rail. */
.music-flat-track { padding: 5px 8px; }
-/* ── Persistent player bar (docs/musicbay.md §2.3) ────────────────────────
+/* ── Persistent player bar (docs/MESHBAY_DESIGN.md §9.8) ──────────────────
`position: sticky`, not `fixed` — deliberately: CLAUDE.md's own history
records more than one layout bug from a fixed-position element quietly
double-reserving space against a page that also sized itself against the
@@ -4571,7 +4571,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.music-player-volume { display: none; }
}
-/* ── Photos app (photos-app.js, docs/photos.md) ───────────────────────────── */
+/* ── Photos app (photos-app.js, docs/MESHBAY_DESIGN.md §9.9) ──────────────── */
.photo-toolbar {
display: flex;
@@ -4924,7 +4924,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.folder-field-tbl .sdt-col-dir { font-size: 0.9em; }
.folder-field-actions { display: flex; align-items: center; gap: 10px; }
-/* HelloWorld (docs/refactor-groups.md §4.1) — the reference app, hidden
+/* HelloWorld (docs/MESHBAY_DESIGN.md §9.4) — the reference app, hidden
behind ?dev=1. Deliberately plain: it exists to prove the plumbing, and
anything decorative here would be a second thing to keep working. */
.hw-list { list-style: none; margin: 12px 0 0; padding: 0; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 72c831f..f4979f4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -893,7 +893,7 @@ class MeshBayTransport {
openErr = e;
// The passphrase key did not open the bundle. If we hold a recovery
// key and the node kept a recovery copy, try that — Flow B
- // (docs/auth-confirm.md §4.5): recovering an identity after a lost
+ // (docs/MESHBAY_DESIGN.md §3.6): recovering an identity after a lost
// passphrase, before re-wrapping it under the new one.
if (this._recoveryKey && kpResp.bundle_enc_recovery) {
try {
@@ -1357,7 +1357,7 @@ class MeshBayTransport {
}
/**
- * TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4).
+ * TMDB metadata for one file (Videos app, docs/MESHBAY_DESIGN.md §9.7).
* Keyed by the entry's own `id` (its content hash) — never a path: a
* path names the *folder* a file is in (indexer.py's `_virtual_dir`), so
* two files sharing a folder (any multi-episode season) would resolve to
@@ -1388,7 +1388,7 @@ class MeshBayTransport {
}
/**
- * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's
+ * One season's own overview/air_date/poster (docs/MESHBAY_DESIGN.md §9.7's
* per-season view) — a show's own tmdb_meta is one static field that does
* not necessarily describe every season alike, found live: a 3-season
* show whose overview read as season-3-specific for every season.
@@ -1445,7 +1445,7 @@ class MeshBayTransport {
/**
* Drop one file's cached TMDB match so it re-resolves with the node's
- * current matcher (§10.1/V13) — the one-click alternative to the full
+ * current matcher (V13) — the one-click alternative to the full
* search-and-pick flow. Signed for the same reason as overrideTmdbMatch.
*/
async rematchTmdbMatch(fileId, signFn) {
@@ -1466,7 +1466,7 @@ class MeshBayTransport {
* setTmdbEnabled below for the per-group on/off switch). Signed like
* setAppsEnabled/updateRoot — an unsigned change would let any
* member alter outbound third-party network traffic the operator never
- * agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears
+ * agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears
* a previously-set custom token; omit it (undefined/null), like
* `language`, to leave whatever is stored unchanged.
*/
@@ -1598,7 +1598,7 @@ class MeshBayTransport {
}
/**
- * MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3)
+ * MusicBrainz metadata for one track (Music app, docs/MESHBAY_DESIGN.md §9.8)
* — same shape as fetchMediaMeta, minus a season/episode concept:
* album-level (release), resolved from the track's own artist/album
* fields already in the index. Keyed by the track's own `id` (content
@@ -1659,7 +1659,7 @@ class MeshBayTransport {
/**
* Whether MusicBrainz lookups run for this group at all — per-group from
- * the start (docs/musicbay.md §3.2/§6). Signed like setTmdbEnabled.
+ * the start (docs/MESHBAY_DESIGN.md §9.8). Signed like setTmdbEnabled.
*/
async setMusicbrainzEnabled(enabled, signFn) {
const msg = await this._sendAndWait({
@@ -1833,7 +1833,7 @@ class MeshBayTransport {
* Who is in this group and which device keys they hold — verified here, not
* taken on the node's word.
*
- * Tier 2 of `desktop-client-v1.md` §4.8. The node relays, for each device,
+ * Tier 2 of `docs/MESHBAY_DESIGN.md` §3.3. The node relays, for each device,
* the already-pinned key that countersigned it and the signature itself; this
* walks that from each account's first device outwards and keeps only the
* devices it could actually reach. A device the node asserts but cannot
@@ -1843,7 +1843,7 @@ class MeshBayTransport {
* The property this buys, stated exactly: once a client has seen an account,
* a node that later substitutes a key for it is **detected**. It buys nothing
* at first sight, where there is nothing to compare against — that boundary
- * is `per-node-identity-v1.md`'s and does not move.
+ * is `docs/MESHBAY_DESIGN.md` §3.2's and does not move.
*/
async groupRoster() {
if (this._roster) return this._roster;
@@ -1875,8 +1875,9 @@ class MeshBayTransport {
* 'changed' a key this account has not shown before and cannot evidence
*
* Only `changed` is worth a person's attention, and it is the one notice
- * §4.8 budgets for. `first` is not an alarm — every account is new once, and
- * treating that as a warning is how a warning stops being read.
+ * docs/MESHBAY_DESIGN.md §3.3 budgets for. `first` is not an alarm — every
+ * account is new once, and treating that as a warning is how a warning stops
+ * being read.
*/
async accountDeviceStatus(userId, devicePk) {
let roster;
@@ -2658,7 +2659,7 @@ class MeshBayTransport {
// Identity keys are per node, so a browser and a desktop client are two keys
// on one account here. A new one is admitted by a key this node already
// pinned — never by the hub, which holds no user keys and so cannot
- // countersign anything. See docs/desktop-client-v1.md §4.
+ // countersign anything. See docs/MESHBAY_DESIGN.md §3.3.
/**
* Ask to be added, and return the code to show the person.
@@ -3286,7 +3287,7 @@ class MeshBayTransport {
});
}
// Same shape: the operator dropped one file's match to have it
- // re-resolved (§10.1/V13). No tmdbId — the node re-derives it.
+ // re-resolved (V13). No tmdbId — the node re-derives it.
if (msg.type === 'tmdb_rematch_ack' && this._onTmdbOverride) {
this._onTmdbOverride({ fileId: msg.file_id || '', tmdbId: '', mediaType: '' });
}
@@ -4044,7 +4045,7 @@ function pinnedNodeCount() {
// ── Passphrase change: re-wrap every reachable identity bundle ───────────────
//
-// docs/auth-confirm.md §3.2. The passphrase-derived bundle_key encrypts this
+// docs/MESHBAY_DESIGN.md §3.6. The passphrase-derived bundle_key encrypts this
// account's per-node identity on every node it has joined. Changing the
// passphrase changes that key, so each bundle must be read with the old key and
// written back with the new one — on the node, while both keys are in hand.
@@ -4086,7 +4087,8 @@ function _acWithTimeout(promise, ms, label) {
* @param {string} o.userId
* @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy
* @param {string} o.newPassphrase
- * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B, docs/auth-confirm.md §4.5).
+ * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B,
+ * docs/MESHBAY_DESIGN.md §3.6).
* When given, the recovery-wrapped copy is read where the
* passphrase copy cannot be, and a fresh one is written back.
* @param {(p:{done:number,total:number})=>void} [o.onProgress]
@@ -4100,7 +4102,7 @@ async function rewrapAllNodes(o) {
let oldKey, newKey;
if (o.bundleKey) {
// "Keep the current passphrase key, just add / refresh the recovery copy"
- // — the Profile backfill (docs/auth-confirm.md §4.3). `o.bundleKey` is the
+ // — the Profile backfill (docs/MESHBAY_DESIGN.md §3.6). `o.bundleKey` is the
// live {v2,v1} session key, so no passphrase is needed.
oldKey = newKey = o.bundleKey;
} else {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
index 5c79bc5..4662c25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
@@ -23,7 +23,7 @@ const TMDB_LANGUAGE_BY_LOCALE = {
* The TMDB parts are two independent settings that happen to sit together:
* the on/off switch is per group, while the API key and the query language
* are node-wide, because they are one operator's credential and one shared
- * cache (docs/mediacenter.md §5.5). They save separately for that reason.
+ * cache (docs/MESHBAY_DESIGN.md §9.7). They save separately for that reason.
*/
function VideoSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) {
const { busy, msg, run } = useSaver();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index d4af4ef..d4ec01a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -10,16 +10,16 @@ import { usePager, Pager, pageSizeFrom } from './pager.js';
// ── Videos ───────────────────────────────────────────────────────────────────
//
// A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a
-// group's video files, per docs/mediacenter.md. Grouping: one card per movie,
-// one card per show — shows are grouped by `display_title` (already
+// group's video files, per docs/MESHBAY_DESIGN.md §9.7. Grouping: one card per
+// movie, one card per show — shows are grouped by `display_title` (already
// resolved/corroborated at index time, §3.4), not by folder path, since a
// client-side path convention would have to guess how many roots/subfolders
// deep a show folder sits, which display_title already settled once.
//
// TMDB metadata is fetched lazily, only for a tile once it is actually
-// visible (LazyTile below) — apps.md §5's virtualization requirement for a
-// grid of many tiles. Thumbnails go through the same `file_req`/chunk path
-// as a real file (docs/mediacenter.md §5.3) via MediaThumb, reusing
+// visible (LazyTile below) — the virtualization requirement for a grid of
+// many tiles. Thumbnails go through the same `file_req`/chunk path
+// as a real file (docs/MESHBAY_DESIGN.md §6.5) via MediaThumb, reusing
// chat-app.js's ChatImage pattern.
const VIEW_MODE_KEY = 'meshbay_video_view_mode';
@@ -121,7 +121,7 @@ function groupVideoEntries(entries, videoDirectories) {
return { movies, shows };
}
-// ── lazy-mount tile (apps.md §5 virtualization) ─────────────────────────────
+// ── lazy-mount tile (virtualization) ───────────────────────────────────────
const LAZY_TILE_MARGIN = 300;
@@ -485,7 +485,7 @@ function OverviewText({ text, reserve }) {
`;
}
-// ── season picker (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─
+// ── season picker (docs/MESHBAY_DESIGN.md §9.7's per-season text) ────────────
//
// A row of tabs, which this was, scrolls horizontally once a show has more
// seasons than fit — a scrollbar nobody finds, hiding the seasons that matter
@@ -708,7 +708,7 @@ function VideoDetailModal({
const [rematching, setRematching] = useState(false);
const mediaType = show ? 'tv' : 'movie';
- // §10.1/V13: drop this file's cached match on the node and let it
+ // V13: drop this file's cached match on the node and let it
// re-resolve with the current matcher — the one-click alternative to the
// full search-and-pick flow above.
const doRematch = useCallback(async () => {
@@ -779,10 +779,11 @@ function VideoDetailModal({
`}
${/* Both of these act on a TMDB match, and with TMDB off for this
group there is none to act on: "Fix match" opens a search the
- node answers with an empty result list (§5.7's silent
- degradation), and "Rematch" drops a cached match that was
- never made. Two buttons that cannot do anything, offered to
- the one person who already knows why. */''}
+ node answers with an empty result list
+ (docs/MESHBAY_DESIGN.md §9.7's silent degradation), and
+ "Rematch" drops a cached match that was never made. Two
+ buttons that cannot do anything, offered to the one person
+ who already knows why. */''}
${isNodeAdmin && tmdbEnabled && html`
<div class="video-admin-actions">
<button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}>
@@ -1182,9 +1183,10 @@ function VideoApp({
`;
}
-// MediaThumb and LazyTile are also used by music-app.js (docs/musicbay.md
-// §7.1): the same "decrypt a thumb_hash via the chunk path into a cached
+// MediaThumb and LazyTile are also used by music-app.js
+// (docs/MESHBAY_DESIGN.md §9.8): the same "decrypt a thumb_hash via the
+// chunk path into a cached
// blob" and "mount only once actually scrolled near" mechanisms apply to a
// track's cover art unchanged, so Music imports them here rather than
-// re-implementing (apps.md §4's checklist).
+// re-implementing (docs/MESHBAY_DESIGN.md §9.4's checklist).
export { VideoApp, MediaThumb, LazyTile, groupVideoEntries, bumpMediaMetaGeneration, bumpThumbGeneration };
diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
index 74b4bb3..c05e23b 100644
--- a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
@@ -78,7 +78,6 @@ const shown = []; // [{ at, groups }] — one entry per render the reader gets
const session = { bundleKey: 'k' };
const _loadBundleKey = async () => 'k';
-const cacheGroupIndex = () => {};
// One group's index, answered on the clock rather than over a network.
const fetchGroupIndex = (groupId) => new Promise((resolve, reject) => {
@@ -106,7 +105,7 @@ const lift = (signature) => {
};
const make = new Function(
- 'session', '_loadBundleKey', 'cacheGroupIndex', 'fetchGroupIndex', 'localStorage',
+ 'session', '_loadBundleKey', 'fetchGroupIndex', 'localStorage',
`const MAX_IN_FLIGHT = ${ceiling[1]};
const DOWN_KEY = 'harness';
${lift('function lastKnownDown(')}
@@ -116,7 +115,7 @@ const make = new Function(
return fetchAllIndexes;`,
);
const fetchAllIndexes = make(
- session, _loadBundleKey, cacheGroupIndex, fetchGroupIndex, globalThis.localStorage);
+ session, _loadBundleKey, fetchGroupIndex, globalThis.localStorage);
// ── The scenario ─────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index d1a4dcb..2be7c03 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -718,3 +718,70 @@ async def test_a_private_groups_node_list_is_for_its_members(client):
finally:
rev._connected_nodes.pop(node_id, None)
rev._node_groups.pop(node_id, None)
+
+
+async def _announce_key(client, user: dict, sk) -> int:
+ """Announce a *distinct* node key, and return the status code."""
+ from meshbay_common.crypto import pk_to_b64
+
+ pk = pk_to_b64(sk.public_key())
+ ts = int(time.time())
+ msg = f"meshbay:node_announce:{user['user_id']}:{pk}:{ts}".encode()
+ r = await client.post("/v1/nodes/announce", json={
+ "pk_node": pk, "endpoint_hint": "test", "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(msg)).decode(),
+ }, headers={"Authorization": f"Bearer {user['token']}"})
+ return r.status_code
+
+
+async def test_one_account_cannot_announce_unlimited_nodes(client, monkeypatch):
+ """
+ Each new node key is a row in `nodes` and a row in the IP log, and the IP log
+ is kept for a year. Proof of possession (M8) settles *whose* key it is and
+ says nothing about how many: an account in a loop wrote a year of storage on
+ the operator's disk having paid only for signatures.
+
+ Two accounts, because the ceiling has to be per account. One that is shared
+ would let a single member deny every other member the ability to bring a
+ machine online, which is the same defect with better manners.
+ """
+ from meshbay_hub.api import nodes as nodes_api
+
+ monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 3)
+ alice = await _make_user(client, "av_nodecap_alice")
+ bob = await _make_user(client, "av_nodecap_bob")
+
+ keys = [Ed25519PrivateKey.generate() for _ in range(4)]
+ for sk in keys[:3]:
+ assert await _announce_key(client, alice, sk) == 201
+
+ assert await _announce_key(client, alice, keys[3]) == 409, (
+ "an account announced past the ceiling")
+
+ # Bob has announced nothing and must be unaffected.
+ assert await _announce_key(client, bob, Ed25519PrivateKey.generate()) == 201, (
+ "one account's ceiling was charged to another's"
+ )
+
+
+async def test_a_node_at_the_ceiling_can_still_refresh_its_address(client, monkeypatch):
+ """
+ The ceiling counts rows, so it must be checked only where a row is added.
+ Applied to every announce, it would freeze the address of every node an
+ account already runs the moment it reached the limit — and a node that
+ cannot re-announce is a node nobody can reach after their ISP renumbers
+ them, which is an outage caused by the protection.
+ """
+ from meshbay_hub.api import nodes as nodes_api
+
+ monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 2)
+ alice = await _make_user(client, "av_nodecap_refresh")
+
+ keys = [Ed25519PrivateKey.generate() for _ in range(2)]
+ for sk in keys:
+ assert await _announce_key(client, alice, sk) == 201
+ assert await _announce_key(client, alice, Ed25519PrivateKey.generate()) == 409
+
+ for sk in keys:
+ assert await _announce_key(client, alice, sk) == 201, (
+ "a node already known could not re-announce at the ceiling")
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index 5862ac3..fcf6c4d 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -8,8 +8,8 @@ the design depends on are present in the source, and it fails if one is removed
— which is the same treatment `test_downloads.py` gives the three
browser-specific save paths, for the same reason.
-Every assertion here corresponds to a sentence in `docs/desktop-client-v1.md`
-§3. Weak evidence, and the only evidence available without a packaged build; a
+Every assertion here corresponds to a sentence in `docs/MESHBAY_DESIGN.md`
+§8.2. Weak evidence, and the only evidence available without a packaged build; a
person with an installed client is what confirms the rest.
"""
@@ -370,9 +370,10 @@ def test_plain_http_is_refused_except_to_loopback():
def test_the_interface_is_copied_not_forked():
"""
- §2.7: the hub's static directory is the single source. A silent fork is the
- only real way to end up maintaining the interface twice, so the copy is
- generated and the generated tree is not committed.
+ docs/MESHBAY_DESIGN.md §8.3: the hub's static directory is the single
+ source. A silent fork is the only real way to end up maintaining the
+ interface twice, so the copy is generated and the generated tree is not
+ committed.
"""
sync = (CLIENT / "scripts" / "sync-ui.js").read_text(encoding="utf-8")
assert "meshbay-hub" in sync and "static" in sync
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index afb85d6..395053b 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -146,9 +146,9 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path):
assert "if (entry.size > 0)" in src
# The zip-directory download started in files-app.js (group-page refactor)
- # and was lifted into file-utils.js's downloadDirectory (docs/photos.md
- # §3) so photos-app.js's own "zip this album" button calls the same
- # implementation rather than a second one.
+ # and was lifted into file-utils.js's downloadDirectory
+ # (docs/MESHBAY_DESIGN.md §9.9) so photos-app.js's own "zip this album"
+ # button calls the same implementation rather than a second one.
# Anchored on the call, not on how its result is bound: the assignment
# became a bare `target = ...` inside a try when _openDownloadTarget gained
# the ability to refuse an oversized download (test_memory_ceiling.py).
diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py
index a1baf92..451b57b 100644
--- a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py
+++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py
@@ -1,7 +1,7 @@
"""
The reference application, and what it is for.
-`docs/refactor-groups.md` claims that adding an application costs a registry
+`docs/MESHBAY_DESIGN.md` §9.4 claims that adding an application costs a registry
entry and the app's own files — no op, no MNP message, no route, no edit to the
pages that render it. Every other test of that claim reads source for the
*absence* of app names, which proves nobody wrote a special case for Videos. It
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 78561b6..3c82bb0 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -37,7 +37,7 @@ STATIC_FILES = [
"video-player.js", "video-app.js", "music-app.js", "music-player.js",
"photos-app.js", "pager.js",
"group-settings.js",
- # The per-app settings architecture (docs/refactor-groups.md §3). Reached
+ # The per-app settings architecture (docs/MESHBAY_DESIGN.md §9.4). Reached
# through the apps.js registry rather than imported by name, so a file
# left out of this list is one nothing checks — the failure is silent.
"settings-ui.js", "folder-tree.js",
diff --git a/packages/meshbay-hub/tests/test_no_index_cache.py b/packages/meshbay-hub/tests/test_no_index_cache.py
new file mode 100644
index 0000000..c39bf00
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_no_index_cache.py
@@ -0,0 +1,98 @@
+"""
+A group's index is never written to browser storage.
+
+`group_indexes` was an IndexedDB store holding a decrypted copy of every group's
+index — each file's name, path, size, hash and uploader — written on every index
+and every delta. The cross-group search of the time read it instead of dialling
+anything, which is what it was for.
+
+Search has dialled the nodes since 2026-08-28. That change removed the reader and
+kept the writers, so the browser went on building a cleartext file listing that
+nothing consulted, that no sign-out removed (the key database is a different
+one), and that grew with every group ever opened. **L7**, at rest.
+
+Showing a group's files while its node is unreachable is the only thing such a
+cache buys, and it is not wanted: a listing you cannot open is worse than an
+honest absence. So there is nothing left to read it with, and these tests keep it
+that way — a writer reintroduced without a reader would be invisible again, and
+the second time it would be invisible for the same reason as the first.
+"""
+
+import re
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+HUB_CLIENT = STATIC / "hub-client.js"
+APP = STATIC / "app.js"
+
+# The store name, read from the source rather than written down here: renaming it
+# must not quietly take these tests out of the picture.
+STORE = re.search(r"const IDB_STORE = '([^']+)';",
+ HUB_CLIENT.read_text(encoding="utf-8")).group(1)
+
+
+def _functions(src: str) -> dict[str, str]:
+ """Every top-level function in a module, by name."""
+ out = {}
+ starts = [(m.start(), m.group(1)) for m in
+ re.finditer(r"^(?:async )?function (\w+)\(", src, re.M)]
+ for i, (at, name) in enumerate(starts):
+ end = starts[i + 1][0] if i + 1 < len(starts) else len(src)
+ out[name] = src[at:end]
+ return out
+
+
+def test_only_the_purge_touches_the_old_store():
+ """
+ Creating it and emptying it, and nothing else.
+
+ `openDB` still creates the store because dropping it needs a version bump,
+ and a version bump is an upgrade another tab can block — which would take
+ playlists down with it, since they share this database. An empty store costs
+ nothing; the point is that nothing writes to it.
+ """
+ fns = _functions(HUB_CLIENT.read_text(encoding="utf-8"))
+ touching = sorted(n for n, body in fns.items() if "IDB_STORE" in body)
+ assert touching == ["openDB", "purgeGroupIndexCache"], (
+ f"{touching} touch the {STORE!r} store; only creating and emptying it "
+ "are allowed, and a write to it is a file listing kept on disk that "
+ "nothing will ever read")
+
+
+def test_nothing_writes_a_group_index_to_the_store():
+ """Stated on the operation rather than on the callers, so a new one is caught."""
+ fns = _functions(HUB_CLIENT.read_text(encoding="utf-8"))
+ purge = fns["purgeGroupIndexCache"]
+ assert ".clear()" in purge
+ for write in (".put(", ".add(", ".putAll("):
+ assert write not in purge, f"the purge does a {write} — it must only clear"
+
+
+def test_no_module_carries_a_cache_writer_any_more():
+ """
+ The functions are gone, so the way this comes back is a new one. Any export
+ of hub-client.js whose name is about caching an index is refused here rather
+ than discovered months later with a store full of filenames.
+ """
+ src = HUB_CLIENT.read_text(encoding="utf-8")
+ for name in re.findall(r"^(?:async )?function (\w+)\(", src, re.M):
+ assert not re.search(r"cache.*index|index.*cache", name, re.I) \
+ or name == "purgeGroupIndexCache", (
+ f"{name} looks like an index cache again — the store it would write "
+ "to has no reader, and adding one was decided against")
+
+
+def test_the_purge_is_actually_called():
+ """
+ The defect being cleaned up was a function nobody called. A purge nobody
+ calls is the same defect wearing the opposite hat: the data stays on every
+ machine that already has it, and nothing says so.
+ """
+ app = APP.read_text(encoding="utf-8")
+ assert "purgeGroupIndexCache" in app, "app.js no longer imports the purge"
+ call = re.search(r"purgeGroupIndexCache\(\)", app)
+ assert call, "the purge is imported and never called"
+ mount = app.index("const mount = () => {")
+ assert "purgeOnce()" in app[mount:mount + 400], (
+ "the purge is no longer run at start-up, so a browser that still holds "
+ "the old store keeps it")
diff --git a/packages/meshbay-hub/tests/test_password_change.py b/packages/meshbay-hub/tests/test_password_change.py
index 4d2f303..b2fe586 100644
--- a/packages/meshbay-hub/tests/test_password_change.py
+++ b/packages/meshbay-hub/tests/test_password_change.py
@@ -1,5 +1,5 @@
"""
-Passphrase change — Flow A of docs/auth-confirm.md.
+Passphrase change — Flow A of docs/MESHBAY_DESIGN.md §3.6.
The hub's part is small: re-prove the current passphrase, swap the auth_key
verifier, invalidate every other session, keep the caller's. The re-wrapping of
diff --git a/packages/meshbay-hub/tests/test_password_reset.py b/packages/meshbay-hub/tests/test_password_reset.py
index 1273315..823807c 100644
--- a/packages/meshbay-hub/tests/test_password_reset.py
+++ b/packages/meshbay-hub/tests/test_password_reset.py
@@ -1,5 +1,5 @@
"""
-Passphrase reset by e-mail code — Flow B of docs/auth-confirm.md §4.2.
+Passphrase reset by e-mail code — Flow B of docs/MESHBAY_DESIGN.md §3.6.
The hub's part re-opens sign-in only: it swaps the auth_key verifier, kills
every session, and drops every registered device key so a stored one cannot
diff --git a/packages/meshbay-hub/tests/test_recovery_email.py b/packages/meshbay-hub/tests/test_recovery_email.py
index 07880d0..96dde5d 100644
--- a/packages/meshbay-hub/tests/test_recovery_email.py
+++ b/packages/meshbay-hub/tests/test_recovery_email.py
@@ -1,5 +1,5 @@
"""
-The recovery key in the registration e-mail (docs/auth-confirm.md §4.4).
+The recovery key in the registration e-mail (docs/MESHBAY_DESIGN.md §3.6).
When the client sends `recovery_key`, the hub appends it to the verification
e-mail and stores it nowhere. When it does not, the e-mail carries only the
diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py
index 378758a..54415f6 100644
--- a/packages/meshbay-hub/tests/test_recovery_key.py
+++ b/packages/meshbay-hub/tests/test_recovery_key.py
@@ -1,5 +1,5 @@
"""
-The account recovery key (docs/auth-confirm.md §4.3).
+The account recovery key (docs/MESHBAY_DESIGN.md §3.6).
`generateRecoveryKey` / `deriveRecoveryKey` in keyderive.js are run here under
node against the real WebCrypto, rather than reimplemented: the mnemonic has to
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
index 03d24dc..54ef67a 100644
--- a/packages/meshbay-hub/tests/test_rewrap_fanout.py
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -1,6 +1,6 @@
"""
`MeshBayTransport.rewrapAllNodes` — the passphrase-change / recovery fan-out
-(docs/auth-confirm.md §3.2, §4.5).
+(docs/MESHBAY_DESIGN.md §3.6).
The real function is run under node with its two boundaries stubbed: the hub
HTTP calls and the per-node `MeshBayTransport` handshake. What is exercised is
diff --git a/packages/meshbay-hub/tests/test_search_files_unmerged.py b/packages/meshbay-hub/tests/test_search_files_unmerged.py
index 6956dde..b84b25d 100644
--- a/packages/meshbay-hub/tests/test_search_files_unmerged.py
+++ b/packages/meshbay-hub/tests/test_search_files_unmerged.py
@@ -19,7 +19,7 @@ merge. Weak evidence, and the only kind available for the SPA — but the failur
it guards against is a one-line edit, which is exactly what a source-reading
test catches well.
-See docs/refactoring-search.md §6.1.
+See docs/MESHBAY_DESIGN.md §9.11.
"""
import re
@@ -54,7 +54,7 @@ def test_the_files_list_is_not_merged():
"fileEntries now goes through the source merge. The Files explorer "
"shows one folder per group and a member navigates into it; merging "
"two groups' copies of a file would remove it from one of those "
- "folders. See docs/refactoring-search.md §6.1")
+ "folders. See docs/MESHBAY_DESIGN.md §9.11")
@pytest.mark.parametrize("name", ["videoEntries", "musicEntries", "photoEntries"])
diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py
index 65f85aa..3464902 100644
--- a/packages/meshbay-hub/tests/test_search_media_merge.py
+++ b/packages/meshbay-hub/tests/test_search_media_merge.py
@@ -23,7 +23,7 @@ with it — so what this counts is what the grid renders.
`t()` is stubbed to return its key: `groupMusicEntries` uses it for the two
placeholder album names, and a string is not what is under test here.
-See docs/refactoring-search.md.
+See docs/MESHBAY_DESIGN.md §9.11.
"""
import json
diff --git a/packages/meshbay-hub/tests/test_search_source_merge.py b/packages/meshbay-hub/tests/test_search_source_merge.py
index cadf095..b471891 100644
--- a/packages/meshbay-hub/tests/test_search_source_merge.py
+++ b/packages/meshbay-hub/tests/test_search_source_merge.py
@@ -20,7 +20,7 @@ it has no imports precisely so that it can be, and a copy of the picking rule
in a test would keep agreeing with the original right up until one of them
changed.
-See docs/refactoring-search.md.
+See docs/MESHBAY_DESIGN.md §9.11.
"""
import json
diff --git a/packages/meshbay-hub/tests/test_search_unlisted.py b/packages/meshbay-hub/tests/test_search_unlisted.py
index 67eb3e6..94f3aee 100644
--- a/packages/meshbay-hub/tests/test_search_unlisted.py
+++ b/packages/meshbay-hub/tests/test_search_unlisted.py
@@ -48,10 +48,14 @@ def test_an_unlisted_group_is_neither_indexed_nor_cached_nor_unreachable():
body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchAllIndexes")
branch = body[body.index("result.unlisted"):]
branch = branch[:branch.index("} else if (result)")]
- for forbidden in ("results.set", "cacheGroupIndex", "unreachable.push"):
+ # `cacheGroupIndex` used to be on this list. The store it wrote to is gone
+ # (hub-client.js `purgeGroupIndexCache`), so the way an unlisted group's
+ # index could now be kept is by being written anywhere at all — which is
+ # what test_no_group_index_is_written_to_storage guards, for every group.
+ for forbidden in ("results.set", "unreachable.push"):
assert forbidden not in branch, (
- f"an unlisted group reaches `{forbidden}` — it would be shown, "
- "cached, or reported as down")
+ f"an unlisted group reaches `{forbidden}` — it would be shown "
+ "or reported as down")
def test_every_search_view_is_built_from_the_indexed_groups_only():
diff --git a/packages/meshbay-hub/tests/test_sticky_band_ring.py b/packages/meshbay-hub/tests/test_sticky_band_ring.py
index 2d57822..e5ef860 100644
--- a/packages/meshbay-hub/tests/test_sticky_band_ring.py
+++ b/packages/meshbay-hub/tests/test_sticky_band_ring.py
@@ -28,9 +28,10 @@ before `test_sticky_header.py` measured it.
Read out of the stylesheet rather than measured in a browser, deliberately.
A browser shows the 4px at one width, in one of the states that happen to put
something above a band; what has to hold is which bands are in which of two
-lists, and that is a fact about the source. `docs/apps.md` sends the author of
-a new application here to make its toolbar pin, and this is what says whether
-the toolbar they add needs the gap — it does not, and it must not have it.
+lists, and that is a fact about the source. `docs/MESHBAY_DESIGN.md` §9.2 sends
+the author of a new application here to make its toolbar pin, and this is what
+says whether the toolbar they add needs the gap — it does not, and it must not
+have it.
"""
import re
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 9c3a88b..3c2b0b6 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -141,7 +141,7 @@ def test_the_view_only_follows_new_messages_when_already_at_the_bottom(chat):
def test_every_authorize_admin_op_call_is_registered_in_admin_op_types(transport):
"""
- Found live (docs/photos.md's photo_roots): `setPhotoRoots` called
+ Found live (docs/MESHBAY_DESIGN.md §9.9's photo_roots): `setPhotoRoots` called
`_authorizeAdminOp(msg, 'photo_roots', ...)` like every other admin op,
but `photo_roots` was never added to `ADMIN_OP_TYPES` — so its initial
request was never keyed `admin:photo_roots`, the node's `admin_challenge`
diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py
index 9471b8a..26c5552 100644
--- a/packages/meshbay-hub/tests/test_zip_size_limit.py
+++ b/packages/meshbay-hub/tests/test_zip_size_limit.py
@@ -3,7 +3,7 @@ The arbitrary ceiling on a directory zip.
`downloadDirectory` is the one implementation behind every "download this
folder as a zip" button — Files' single folder, Files' multi-folder selection,
-and the Photos album button (docs/photos.md §3) — so the limit is checked
+and the Photos album button (docs/MESHBAY_DESIGN.md §9.9) — so the limit is checked
once, there, and holds for all of them.
Three things are worth pinning. That an oversized folder is refused *before*
diff --git a/packages/meshbay-node/src/meshbay_node/bundle_store.py b/packages/meshbay-node/src/meshbay_node/bundle_store.py
index c65b94d..36792ac 100644
--- a/packages/meshbay-node/src/meshbay_node/bundle_store.py
+++ b/packages/meshbay-node/src/meshbay_node/bundle_store.py
@@ -6,7 +6,7 @@ Keypair bundles: AES-GCM encrypted (Ed25519 + X25519) private keys, encrypted
with the user's password-derived bundle_key. Opaque to the node. An optional
second copy (bundle_enc_recovery) is wrapped under the account's recovery key
instead, so a forgotten passphrase does not strand the identity — see
-docs/auth-confirm.md §4.3.
+docs/MESHBAY_DESIGN.md §3.6.
Both are stored and served over the P2P DataChannel during MNP handshake.
"""
@@ -168,7 +168,7 @@ class BundleStore:
copy wrapped under the account's recovery key.
A call that omits bundle_enc_recovery — a plain re-backup, or a
- passphrase-change re-wrap (docs/auth-confirm.md §3.2) — must not erase a
+ passphrase-change re-wrap (docs/MESHBAY_DESIGN.md §3.6) — must not erase a
recovery copy already stored, so the upsert keeps the existing value
when the new one is None.
"""
diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py
index 9e5ee90..17cd68e 100644
--- a/packages/meshbay-node/src/meshbay_node/chat/store.py
+++ b/packages/meshbay-node/src/meshbay_node/chat/store.py
@@ -4,7 +4,7 @@ MeshBay Node — SQLite-backed chat message store.
One database per group. The node is a relay and an archive: it stores what it
was handed, serves it back, and — once a group has chat encryption switched on —
cannot read any of it. Decryption happens in the client, which is the only place
-that holds the epoch key (`docs/chat-sender-keys.md` §5).
+that holds the epoch key (`docs/MESHBAY_DESIGN.md` §4.5).
Three things about the schema are load-bearing rather than incidental:
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 478faae..3492d03 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -192,7 +192,7 @@ class NodeDaemon:
# failure — a persistently unprobeable file (corrupt, still being
# written) does not get re-queued on every coalesced broadcast. A
# restart retries everything, matching the "disposable, rebuildable"
- # stance the rest of this cache takes (docs/mediacenter.md §1/§2).
+ # stance the rest of this cache takes (docs/MESHBAY_DESIGN.md §6.5).
# Shared across the video and audio enrichment paths — content-
# addressed ids never collide between the two. Keyed by
# (group_id, entry.id), not entry.id alone: the id is a content
@@ -438,7 +438,7 @@ class NodeDaemon:
"tmdb_enabled": await self._roster.tmdb_enabled(
group_cfg.id) if self._roster else True,
# Music app equivalent of tmdb_enabled — per-group from
- # the start (docs/musicbay.md §6).
+ # the start (docs/MESHBAY_DESIGN.md §9.8).
"musicbrainz_enabled": await self._roster.musicbrainz_enabled(
group_cfg.id) if self._roster else True,
}
@@ -466,7 +466,7 @@ class NodeDaemon:
# 6b. Media cache (Videos app — TMDB metadata + thumbnails).
# Node-wide like audit.db, not per-group: a thumbnail is the same
# bytes regardless of which group happens to share the file
- # (docs/mediacenter.md §2/§5.5).
+ # (docs/MESHBAY_DESIGN.md §6.5, §9.7).
media_cache_db = data_dir / "media_cache.db"
self._media_cache = MediaCache(db_path=media_cache_db)
await self._media_cache.open()
@@ -483,16 +483,18 @@ class NodeDaemon:
self._state["tmdb_token_customized"] = bool(tmdb_token)
self._state["tmdb_language"] = tmdb_language or ""
- # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own
- # enricher (mutagen, not ffmpeg) and its own MusicBrainz client.
+ # 6c. Music app (docs/MESHBAY_DESIGN.md §9.8) — same
+ # media_cache.db, its own enricher (mutagen, not ffmpeg) and its
+ # own MusicBrainz client.
# The User-Agent contact is the owner's hub email, resolved at
# login — no roster setting or env var needed any more.
self._audio_enricher = AudioEnricher(self._media_cache)
self._musicbrainz_client = MusicBrainzClient(owner_email=session.email)
self._state["musicbrainz_contact_configured"] = bool(session.email)
- # 6d. Photos app (docs/photos.md) — same media_cache.db, its own
- # enricher (Pillow, not ffmpeg/mutagen). No credential, no
+ # 6d. Photos app (docs/MESHBAY_DESIGN.md §9.9) — same
+ # media_cache.db, its own enricher (Pillow, not ffmpeg/mutagen).
+ # No credential, no
# third-party client to construct: EXIF is read locally.
self._photo_enricher = PhotoEnricher(self._media_cache)
self._state["media_cache"] = self._media_cache
@@ -1345,7 +1347,7 @@ class NodeDaemon:
delta = idx.diff(previous)
self._last_broadcast_snapshot[group_id] = (idx.version, idx.entries_by_id())
- # Videos app (docs/mediacenter.md §5.2): schedule async technical
+ # Videos app (docs/MESHBAY_DESIGN.md §6.5): schedule async technical
# probe + title parse + thumbnail generation for every newly-seen
# video entry under the group's configured video_root. Never blocks
# this broadcast — enrichment fields arrive later as their own
@@ -1370,18 +1372,19 @@ class NodeDaemon:
seen = {e.id for e in new_entries}
new_entries = new_entries + [e for e in rebuilt if e.id not in seen]
spawn(self._enrich_new_video_entries(indexer, new_entries))
- # Music app (docs/musicbay.md §6): same shape, gated on audio_root
- # exactly like video_root above (added later — musicbay.md's
- # original "no root, whole shared tree" call didn't hold up).
+ # Music app (docs/MESHBAY_DESIGN.md §9.8): same shape, gated on
+ # audio_root exactly like video_root above (added later — the original
+ # "no root, whole shared tree" call didn't hold up).
spawn(self._enrich_new_audio_entries(indexer, new_entries))
- # Photos app (docs/photos.md §5): same shape, gated on photo_roots
- # (a list, not a single string — §2.1).
+ # Photos app (docs/MESHBAY_DESIGN.md §9.9): same shape, gated on
+ # photo_roots (a list, not a single string).
spawn(self._enrich_new_photo_entries(indexer, new_entries))
# A rename/move changes the very filename (or season folder) that
- # §3.3/§3.4's title-parse read display_title/season/episode from,
- # but leaves the file's content — and so its id and everything
- # ffprobe/thumbnailing already found — untouched. Only entries
+ # docs/MESHBAY_DESIGN.md §9.7's title-parse read
+ # display_title/season/episode from, but leaves the file's content —
+ # and so its id and everything ffprobe/thumbnailing already found —
+ # untouched. Only entries
# whose name or path actually differ from the last broadcast get a
# fresh pass; an update that is enrichment's own field-fill
# (duration/thumb_hash/... landing via _on_enriched below) leaves
@@ -1396,13 +1399,13 @@ class NodeDaemon:
# Videos/Music/Photos apps: a file that leaves the index also loses
# its thumbnail/cover and file->tmdb/file->mbid mapping — the "real
- # deletion obligation" docs/mediacenter.md §2/§8 calls out
- # explicitly rather than leaving implicit (docs/musicbay.md §6
+ # deletion obligation" docs/MESHBAY_DESIGN.md §6.5 calls out
+ # explicitly rather than leaving implicit (docs/MESHBAY_DESIGN.md §9.8
# follows the same rule). tmdb_meta/mbid_meta rows are left alone
- # (§2: shared across files).
+ # (shared across files).
#
- # Found live (docs/photos.md): a root removed and a new one added
- # for the identical content (an operator renaming/relocating a
+ # Found live (docs/MESHBAY_DESIGN.md §9.9): a root removed and a new
+ # one added for the identical content (an operator renaming/relocating a
# shared folder) pruned the thumbnail here — correctly, the content
# is gone from *this* root — but left the hash in
# `_enriched_attempted`, which is never otherwise cleared. The same
@@ -1473,8 +1476,8 @@ class NodeDaemon:
A group with no video_root set yet does not enrich anything — TMDB
lookups and ffmpeg thumbnailing are real, ongoing per-file cost
- (mediacenter.md §5.2/§10), and running them over an operator's whole
- shared index before they have chosen which folder is actually their
+ (docs/MESHBAY_DESIGN.md §6.5), and running them over an operator's
+ whole shared index before they have chosen which folder is actually their
media library would burn both TMDB's rate limit and the node's CPU
on files that were never meant to be in the Videos app at all. Once a
root is set, `_enrich_video_root_now` (called when it changes)
@@ -1553,14 +1556,14 @@ class NodeDaemon:
async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
"""
- Music app (docs/musicbay.md §2.1, §6): fire (never await further)
+ Music app (docs/MESHBAY_DESIGN.md §9.8): fire (never await further)
tag/cover enrichment for unattempted audio entries under the
group's configured audio_root — same gate as
- `_enrich_new_video_entries` above (musicbay.md's original "no root,
- whole shared tree" call turned out wrong against a real messy
- library: everything under every shared folder got mixed together
- with no way to scope it down). `_enriched_attempted` is shared with
- the video path — content-addressed ids never collide across the two.
+ `_enrich_new_video_entries` above (the original "no root, whole
+ shared tree" call turned out wrong against a real messy library:
+ everything under every shared folder got mixed together with no way
+ to scope it down). `_enriched_attempted` is shared with the video
+ path — content-addressed ids never collide across the two.
"""
if not self._audio_enricher or not self._roster:
return
@@ -1589,9 +1592,10 @@ class NodeDaemon:
# not the shared root it lives in — so the ancestor walk
# (enrich_audio._artist_album_from_ancestors) treats a flat
# top-level folder right under the configured Music directory as
- # ambiguous (artist-or-release, musicbay.md §2.1), rather than one
- # level too shallow when that directory is itself a subfolder.
- # With several configured, each file is measured against its own:
+ # ambiguous (artist-or-release, docs/MESHBAY_DESIGN.md §9.8),
+ # rather than one level too shallow when that directory is
+ # itself a subfolder. With several configured, each file is
+ # measured against its own:
# a single shared boundary would be wrong for all but one of them.
self._audio_enricher.spawn(entry, file_path, on_done,
boundaries.get(owner))
@@ -1632,8 +1636,8 @@ class NodeDaemon:
async def _enrich_new_photo_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
"""
- Photos app (docs/photos.md §5): fire (never await further) thumbnail/
- EXIF enrichment for unattempted image entries under any of the
+ Photos app (docs/MESHBAY_DESIGN.md §9.9): fire (never await further)
+ thumbnail/EXIF enrichment for unattempted image entries under any of the
group's configured photo_roots. Same gate shape as
`_enrich_new_video_entries`/`_enrich_new_audio_entries` — no root
configured yet means no work, since thumbnailing every image in a
@@ -1671,8 +1675,9 @@ class NodeDaemon:
so a folder that already had photos in it before it was added to
photo_roots would otherwise never get enriched at all. Also covers
a root being *removed*: nothing un-enriches on removal (the cache
- entry is harmless, just unused — docs/photos.md's cache is
- disposable), so re-sweeping the new set is enough.
+ entry is harmless, just unused — the media cache is disposable and
+ tied to the index, docs/MESHBAY_DESIGN.md §6.5), so re-sweeping the
+ new set is enough.
"""
indexer = self._state.get("indexers", {}).get(group_id)
if not indexer:
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
index 6eeb1ef..b44a246 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
@@ -4,7 +4,7 @@ filename parsing (title_parse), and thumbnail generation (ffmpeg) for a
newly-added video IndexEntry.
Runs through its own small bounded worker pool — separate from the streaming
-transcode pool (docs/mediacenter.md §5.2, mirroring webrtc_server.py's
+transcode pool (docs/MESHBAY_DESIGN.md §6.5, mirroring webrtc_server.py's
`_transcode_semaphore`) — so indexing a large library never blocks on this,
and enrichment never competes with an active viewer for CPU. The scan itself
already put the entry in the index with hash/size/type only; this fills in
@@ -33,26 +33,27 @@ THUMB_WIDTH = 320
# "Show/SeasonFolder/episode.mkv" is the expected shape, with a little slack
# for an extra wrapper folder — not an attempt to find the exact group root.
MAX_ANCESTOR_DEPTH = 4
-# Bounds the "borrow a title from a sibling episode filename" scan (§3.4) so
-# a folder with thousands of files costs a fixed, small amount of work.
+# Bounds the "borrow a title from a sibling episode filename" scan
+# (docs/MESHBAY_DESIGN.md §9.7) so a folder with thousands of files costs a
+# fixed, small amount of work.
MAX_SIBLINGS_CHECKED = 20
# Bounds the whole-show scan _synthetic_episode_number uses to rank a
-# season's files across more than one folder (§3.4c) — a show's total file
-# count, not just one folder's, so this needs more headroom than
-# MAX_SIBLINGS_CHECKED.
+# season's files across more than one folder (docs/MESHBAY_DESIGN.md §9.7) —
+# a show's total file count, not just one folder's, so this needs more
+# headroom than MAX_SIBLINGS_CHECKED.
MAX_SEASON_FILES_CHECKED = 500
def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, Path] | None:
"""
- §3.4/§3.4c: walk up ancestor folders for a season-like one (a numbered
- season, or Specials/Bonus/Extras -> season 0) — season from the first
- (innermost) match, but the show's own name from *above every
- consecutive season-like ancestor*, not just the first one. A
- per-season Bonus folder (`Show/Season N/Bonus/file.ext`) is nested two
- levels inside the show, both of them season-like on their own
- ("Bonus" and "Season N") — stopping at the first would hand back
- "Season N" as the show's name instead of "Show".
+ docs/MESHBAY_DESIGN.md §9.7: walk up ancestor folders for a season-like one
+ (a numbered season, or Specials/Bonus/Extras -> season 0) — season from the
+ first (innermost) match, but the show's own name from *above every
+ consecutive season-like ancestor*, not just the first one. A per-season
+ Bonus folder (`Show/Season N/Bonus/file.ext`) is nested two levels inside
+ the show, both of them season-like on their own ("Bonus" and "Season N") —
+ stopping at the first would hand back "Season N" as the show's name instead
+ of "Show".
Trusted over any per-file guessit title once found: a bare episode
numbering convention with no show name embedded at all
@@ -87,15 +88,17 @@ def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, Path] | None:
def _title_from_siblings(file_path: Path) -> str | None:
"""
- §3.4: an episode filename with no show name in it borrows the title from
- a representative sibling in the same folder, never from the folder name
- alone (an acronym-named show folder is a real, observed case).
+ docs/MESHBAY_DESIGN.md §9.7: an episode filename with no show name in it
+ borrows the title from a representative sibling in the same folder, never
+ from the folder name alone (an acronym-named show folder is a real,
+ observed case).
Requires the sibling to carry its own episode number too, not just a
- title — a folder where every file is a one-off-named Special (§3.4b)
- has plenty of `display_title`s (guessit reads *a* title off nearly
- anything) but none of them name the show; requiring a real episode
- number alongside is what tells apart a genuinely representative sibling
+ title — a folder where every file is a one-off-named Special
+ (docs/MESHBAY_DESIGN.md §9.7) has plenty of `display_title`s (guessit
+ reads *a* title off nearly anything) but none of them name the show;
+ requiring a real episode number alongside is what tells apart a
+ genuinely representative sibling
from another Special just like this one.
"""
try:
@@ -119,11 +122,11 @@ def _title_from_siblings(file_path: Path) -> str | None:
def _synthetic_episode_number(file_path: Path, show_root: Path, season: int) -> int:
"""
- §3.4b/§3.4c: a Specials/Bonus folder's files often carry no episode
- number at all — each is just named after its own one-off title. The
- frontend (video-app.js's buildSeasons) sorts within a season by this
- number but only needs it to provide a stable order, not to mean
- anything beyond that.
+ docs/MESHBAY_DESIGN.md §9.7: a Specials/Bonus folder's files often carry no
+ episode number at all — each is just named after its own one-off title. The
+ frontend (video-app.js's buildSeasons) sorts within a season by this number
+ but only needs it to provide a stable order, not to mean anything beyond
+ that.
Ranked across the *whole show*, not just this file's own folder: season
0 routinely spans more than one folder under the show's root — a
@@ -289,10 +292,11 @@ class Enricher:
and (title_parse.has_episode_marker(entry.name)
or title_parse.year_in(entry.name) is None)):
# No season-like ancestor at all (a flat library) but the
- # filename itself carries season+episode (§3.4) — *and* it
- # is a real marker, not guessit reading a bare number as
- # SxxExx. A movie whose "1080p" tag was truncated to "108",
- # or "1280" left in the name, otherwise parses to S01E08 /
+ # filename itself carries season+episode
+ # (docs/MESHBAY_DESIGN.md §9.7) — *and* it is a real marker,
+ # not guessit reading a bare number as SxxExx. A movie whose
+ # "1080p" tag was truncated to "108", or "1280" left in the
+ # name, otherwise parses to S01E08 /
# S12E80 and gets shelved as a nonexistent series
# (found live 2026-08-29). A genuine flat-dumped episode
# has an explicit SxxExx/1x08/"Episode N" marker; a movie
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
index 4fa08ef..d484e35 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
@@ -1,7 +1,7 @@
"""
Index-time enrichment for the Music group app: embedded tag/cover
extraction (mutagen) and filename-parse fallback for a newly-added audio
-IndexEntry (docs/musicbay.md §2.1, §6).
+IndexEntry (docs/MESHBAY_DESIGN.md §9.8).
Runs through its own small bounded worker pool, the same discipline as the
Videos app's `enrich.py` — separate from any other pool, never blocking a
@@ -15,14 +15,13 @@ block the event loop.
MusicBrainz lookups are **not** done here. Tag/cover extraction is free and
local, so it runs for every audio file the Music app is enabled for,
regardless of whether MusicBrainz itself is turned on for the group — the
-flat view (docs/musicbay.md §5.2) needs nothing more than this. MusicBrainz
+flat view (docs/MESHBAY_DESIGN.md §9.8) needs nothing more than this. MusicBrainz
is a separate, lazy, per-request enrichment (`music_meta_req`, handled in
webrtc_server.py), the same "fetched on demand, cached once" shape TMDB
already uses.
**Revised 2026-08-24** against a real ~5700-file library (folder-per-artist
-mostly, but not uniformly — see musicbay.md's own "what got measured" note
-if one gets added). Two findings drove this revision, both confirmed with
+mostly, but not uniformly). Two findings drove this revision, both confirmed with
real data before writing the fix:
1. The original ancestor walk always went up two levels (parent = album,
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
index 93a68cc..6613f85 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
@@ -3,7 +3,7 @@ Index-time enrichment for the Photos group app: a resized thumbnail and a
minimal, best-effort info set (`taken_at`, `camera`) read from the image's
own EXIF block, for a newly-added image IndexEntry.
-Deliberately small — docs/photos.md §2.4 is explicit that this app does not
+Deliberately small — docs/MESHBAY_DESIGN.md §9.9 is explicit that this app does not
build a full EXIF-viewer panel. Two fields only, both best-effort (missing
EXIF is the ordinary case for a screenshot or a re-saved/edited image, not
an error). GPS is never read here, on purpose: it is a location disclosure
@@ -12,7 +12,8 @@ module extracts, caches, or hands it to a caller.
Runs through its own small bounded worker pool, separate from the video
(ffmpeg) and audio (mutagen) enrichment pools — mirrors enrich.py exactly,
-per docs/photos.md §5's "never shared with either" rule, even though
+per docs/MESHBAY_DESIGN.md §6.5's "its own small bounded pool, never the
+streaming pool" rule, even though
Pillow's own work is comparatively cheap: a burst of hundreds of newly
shared photos should not peg every CPU core at once.
"""
@@ -100,7 +101,7 @@ def _read_image(file_path: Path) -> tuple[bytes, int, int, int | None, str | Non
# width/height and resizing — otherwise a phone photo stored
# "sideways" reports its raw, pre-rotation dimensions (swapped from
# what it actually displays as) and produces a sideways thumbnail
- # (docs/photos.md §2.4). Never reads Orientation itself as a
+ # (docs/MESHBAY_DESIGN.md §9.9). Never reads Orientation itself as a
# client-visible field; this is display correction only, and
# width/height must describe the *displayed* image, matching what
# the lightbox and the info panel show.
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index 887d435..3fe4f3e 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -82,7 +82,8 @@ def _is_indexable(path: Path) -> bool:
# Found live: a 1256-byte ".mp3" with no audio stream at all, just an ID3
# tag — a truncated/corrupted rip, sitting between two good tracks of the
-# same album (docs/musicbay.md). A source this small claiming to be audio
+# same album (docs/MESHBAY_DESIGN.md §9.8). A source this small claiming to
+# be audio
# is far more likely broken than real, so it is skipped before ever being
# hashed rather than indexed and left to fail at playback time. Scoped to
# audio only — a tiny real file of any other type is still worth indexing.
@@ -186,9 +187,8 @@ def _size_files(files: list[Path]) -> list[tuple[Path, int]]:
executor for the same reason `_walk_root` is (its own docstring above).
Previously a plain loop straight on the asyncio event loop thread: for a
root with many thousands of files (a real personal library, not a
- hypothetical — docs/musicbay.md's own "several thousand files" example)
- that blocked the entire daemon, every WebRTC session and the admin UI
- included, for as long as the stat() calls took — and did so *before*
+ hypothetical) that blocked the entire daemon, every WebRTC session and
+ the admin UI included, for as long as the stat() calls took — and did so *before*
`_scan_root` had even set `progress.scanning`, so a consumer polling it
saw "not scanning" the whole time real, blocking work was happening.
"""
@@ -673,8 +673,9 @@ class DirectoryIndexer:
Entries under a root that is gone from the config are dropped — the
operator removed it deliberately, which is not the same event as a
- volume disappearing, and conflating the two is what §6.9 exists to
- prevent. Roots that survive keep their entries; new ones are scanned.
+ volume disappearing, and conflating the two is what
+ docs/MESHBAY_DESIGN.md §6.2 exists to prevent. Roots that survive
+ keep their entries; new ones are scanned.
The set takes effect before anything is scanned: the roots table, the
watcher and `self.roots` all move at once. With ``wait=False`` the scan
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
index beb0d3f..14728ff 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -1,18 +1,18 @@
"""
Filename -> title/year/season/episode parsing for the Videos group app.
-Wraps `guessit` and layers the fixes from docs/mediacenter.md §3.3/§3.4 on
+Wraps `guessit` and layers the fixes from docs/MESHBAY_DESIGN.md §9.7 on
top of it: none of them are per-title hacks, each is a generic rule found
by validating guessit's raw output against real TMDB search results over a
~1950-file library (movies, TV shows, and a small franchise set).
-Scope is deliberately narrow (§3.5): title, year, season, episode. Technical
+Scope is deliberately narrow: title, year, season, episode. Technical
facts (resolution, codec, duration) come from ffprobe, never the filename —
a mislabeled `1080p` tag is a real, observed failure mode.
This module never touches the filesystem or the network. The orchestration
that decides *which* file supplies a show's title (a representative episode
-filename, not the folder name — §3.4) lives in the indexer, which has the
+filename, not the folder name) lives in the indexer, which has the
directory listing; this module only parses strings it's handed.
"""
@@ -91,7 +91,7 @@ _YEAR_RE = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
def year_in(text: str) -> int | None:
"""First 19xx/20xx in `text`, or None — used to lift a year off a show
- folder name ("Some.Show.2022.S01") for the search fallback (§10.1/V8)."""
+ folder name ("Some.Show.2022.S01") for the search fallback (V8)."""
m = _YEAR_RE.search(text or "")
return int(m.group(0)) if m else None
@@ -102,7 +102,7 @@ def clean_query(s: str) -> str:
parenthesized-year stripping `naive_title` does. `naive_title` assumes
a real filename; a show's `display_title` is a folder basename
("Some.Show.Name" — `rsplit('.', 1)` would eat ".Name"), so it needs a
- gentler normaliser (§10.1/V8).
+ gentler normaliser (V8).
"""
s = re.sub(r"[._-]+", " ", s or "")
s = _strip_editions(s)
@@ -115,7 +115,7 @@ def _strip_editions(title: str) -> str:
def naive_title(filename: str) -> str:
"""
- The mandated fallback (§3.6, §4.1): strip the extension, replace every
+ The mandated fallback: strip the extension, replace every
`.`/`_`/`-` with a space, drop a trailing parenthesized year, collapse
whitespace. Always computable, never fails, used both as the flat-mode
display name of last resort and as a second TMDB query candidate.
@@ -150,14 +150,14 @@ def sequel_variants(title: str) -> list[str]:
A trailing sequel index often has no exact match in the real TMDB
title: the file has a digit where TMDB uses a Roman numeral (or the
reverse), spells the number out, or wraps it as "Part N" / "Chapitre N"
- (§3.3 row 4, §10.1/V10). Returns extra candidate titles to try — the
+ (V10). Returns extra candidate titles to try — the
index re-rendered as digit and as Roman numeral, plus (only when there
is no "Part"/"Episode"/… keyword) the bare base.
The bare base is withheld for a keyword'd index — "<Saga> Chapter III"
→ "<Saga>" — because a franchise's bare name is very often a real,
*different* film (the series' first entry), and that variant matched
- every later entry to it (§10.1/V14). Without the keyword ("<Franchise>
+ every later entry to it (V14). Without the keyword ("<Franchise>
3") the number is decoration and the bare base is the right thing to
try.
"""
@@ -190,7 +190,7 @@ def sequel_variants(title: str) -> list[str]:
def season_from_folder_name(name: str) -> int | None:
"""
- §3.4: a season-like ancestor folder, vocabulary-driven rather than
+ A season-like ancestor folder, vocabulary-driven rather than
assuming a numeric convention everywhere. A specials/bonus/extras
folder maps to season 0 (matching TMDB's own `season_number: 0`).
Returns None if `name` doesn't look like a season folder at all.
@@ -212,7 +212,8 @@ def season_from_folder_name(name: str) -> int | None:
@dataclass
class ParsedName:
display_title: str | None # None => caller must supply from elsewhere (e.g. a sibling file)
- alt_title: str | None = None # guessit's alternative_title, a second query candidate (§3.3 row 1)
+ alt_title: str | None = None # guessit's alternative_title, a second
+ # query candidate
naive_title: str = "" # always available, fully punctuation-normalized fallback
year: int | None = None
season: int | None = None
@@ -249,7 +250,7 @@ def parse_movie_filename(filename: str) -> ParsedName:
-# ── Music app (docs/musicbay.md §2.1) ────────────────────────────────────────
+# ── Music app (docs/MESHBAY_DESIGN.md §9.8) ────────────────────────────────────────
#
# Filename parsing is the *fallback* here, not the primary source (unlike
# Videos, where guessit does all the work): embedded ID3/Vorbis tags are read
@@ -307,7 +308,7 @@ def strip_track_prefix(text: str) -> str:
# "Season 1"/"Saison 1". guessit will also invent a season+episode from a
# bare 3-4 digit run ("1080p" truncated to "108" -> S01E08; "1280" ->
# S12E80), which is how a plain movie ends up shelved as a series
-# (§10.1/V14). The indexer uses this to tell a real flat-library episode
+# (V14). The indexer uses this to tell a real flat-library episode
# from that hallucination.
_EPISODE_MARKER_RE = re.compile(
r"s\d{1,2}[\s._-]*e\d{1,3}"
@@ -326,9 +327,9 @@ def has_episode_marker(filename: str) -> bool:
def parse_episode_filename(filename: str) -> ParsedName:
"""
Parse an episode filename. `display_title` may come back None (e.g.
- `S08E02.SUBFRENCH.720p.mkv` carries no show name at all, §3.2) — the
+ `S08E02.SUBFRENCH.720p.mkv` carries no show name at all) — the
indexer then supplies the show title from a representative sibling
- filename in the same folder rather than the folder name itself (§3.4).
+ filename in the same folder rather than the folder name itself.
"""
g = guessit(filename)
title = g.get("title")
@@ -351,7 +352,7 @@ def parse_episode_filename(filename: str) -> ParsedName:
)
-# A bare leading episode number, no show name attached (§3.4c) — the same
+# A bare leading episode number, no show name attached — the same
# shape as music's _TRACK_PREFIX_RE, capped at 3 digits for the same reason:
# a leading year ("2010 - Episode.mkv") is 4 digits and must not match.
# guessit's own `episode` is not a substitute here: given exactly 3 digits it
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 2898dea..9dfdf73 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -4,8 +4,8 @@ Videos and Music group apps.
Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as
`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`,
-docs/musicbay.md §6) living in `group_settings` under the `group_id=""`
-sentinel (docs/mediacenter.md §5.5): the credential/budget is one
+docs/MESHBAY_DESIGN.md §9.8) living in `group_settings` under the `group_id=""`
+sentinel (docs/MESHBAY_DESIGN.md §9.7): the credential/budget is one
operator's, and a thumbnail or cover image is the same bytes
regardless of which group happens to share the file. The `file_mbid`/
`mbid_meta` tables below are the Music app's equivalent of `file_tmdb`/
@@ -13,7 +13,8 @@ regardless of which group happens to share the file. The `file_mbid`/
release's cover is cached under a synthetic `musicbrainz:{mbid}` file_id,
the same trick `_fetch_and_cache_poster` uses for a TMDB poster_path).
-Disposable and rebuildable, like the rest of the file index (§1, §2) — never
+Disposable and rebuildable, like the rest of the file index
+(docs/MESHBAY_DESIGN.md §6.5) — never
a second identity for a file. Every row here is keyed off a value the node
can already derive (a file's own blake3 id, or a TMDB id), so losing this
database costs re-probing/re-fetching, not data.
@@ -98,14 +99,14 @@ CREATE TABLE IF NOT EXISTS mbid_meta (
json TEXT NOT NULL,
fetched_at REAL NOT NULL
);
--- Photos app (docs/photos.md): the technical/EXIF fields enrich_photo.py
--- reads alongside the thumbnail. Durable for the same reason `thumbs` is —
--- without this, only the thumbnail bytes survived a restart, and every
--- image was still fully re-decoded through Pillow just to re-derive
--- width/height/taken_at/camera, which get_thumb_hash_by_file_id's own
--- cache hit had already proven unnecessary. thumb_hash is not duplicated
--- here — get_thumb_hash_by_file_id(file_id) already answers that, and a
--- second copy would just be one more place for the two to drift.
+-- Photos app (docs/MESHBAY_DESIGN.md §9.9): the technical/EXIF fields
+-- enrich_photo.py reads alongside the thumbnail. Durable for the same reason
+-- `thumbs` is — without this, only the thumbnail bytes survived a restart, and
+-- every image was still fully re-decoded through Pillow just to re-derive
+-- width/height/taken_at/camera, which get_thumb_hash_by_file_id's own cache
+-- hit had already proven unnecessary. thumb_hash is not duplicated here —
+-- get_thumb_hash_by_file_id(file_id) already answers that, and a second copy
+-- would just be one more place for the two to drift.
CREATE TABLE IF NOT EXISTS photo_meta (
file_id TEXT PRIMARY KEY,
width INTEGER,
@@ -116,10 +117,10 @@ CREATE TABLE IF NOT EXISTS photo_meta (
"""
# TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not
-# need re-checking on this schedule, only the metadata blob (§5.4, V3).
+# need re-checking on this schedule, only the metadata blob (V3).
TMDB_META_TTL_SECS = 30 * 86400
-# Same default as TMDB (docs/musicbay.md §6) — MusicBrainz release data is
+# Same default as TMDB (docs/MESHBAY_DESIGN.md §9.8) — MusicBrainz release data is
# not expected to drift faster; revisit if that proves wrong in practice.
MUSICBRAINZ_META_TTL_SECS = 30 * 86400
@@ -231,7 +232,7 @@ class MediaCache:
Full per-file reset: forget the match *and* any manual override
marker, so the next `media_meta_req` re-resolves from scratch with
the current matcher. This is the explicit operator "re-match this
- one" action (§10.1/V13) — deliberately stronger than
+ one" action (V13) — deliberately stronger than
`clear_file_tmdb`, which spares an override.
"""
await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,))
@@ -282,9 +283,10 @@ class MediaCache:
# ── tmdb id + season number -> season-level metadata json ────────────────
#
# A show's own overview (tmdb_meta above) is one static field an operator
- # found does not necessarily describe every season alike (mediacenter.md
- # §5.4) — this is TMDB's per-season `overview`/`air_date`/`poster_path`,
- # fetched and cached independently, on the same staleness schedule.
+ # found does not necessarily describe every season alike
+ # (docs/MESHBAY_DESIGN.md §9.7) — this is TMDB's per-season
+ # `overview`/`air_date`/`poster_path`, fetched and cached independently,
+ # on the same staleness schedule.
async def get_season_meta(self, tmdb_id: str, season: int) -> dict | None:
async with self._db.execute(
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py
index 81174c8..a6267d9 100644
--- a/packages/meshbay-node/src/meshbay_node/media_probe.py
+++ b/packages/meshbay-node/src/meshbay_node/media_probe.py
@@ -145,7 +145,7 @@ async def probe_video(path: str) -> VideoProbe:
width/height come from the same ffprobe call (one extra `-show_entries`
field, no second process spawn) — resolution is deliberately never
- guessed from the filename (docs/mediacenter.md §3.5).
+ guessed from the filename (docs/MESHBAY_DESIGN.md §9.7).
"""
from meshbay_node.platform import ffprobe_cmd
proc = await asyncio.create_subprocess_exec(
diff --git a/packages/meshbay-node/src/meshbay_node/musicbrainz.py b/packages/meshbay-node/src/meshbay_node/musicbrainz.py
index d5ca1e9..ce2d233 100644
--- a/packages/meshbay-node/src/meshbay_node/musicbrainz.py
+++ b/packages/meshbay-node/src/meshbay_node/musicbrainz.py
@@ -2,7 +2,7 @@
MusicBrainz (musicbrainz.org) + Cover Art Archive (coverartarchive.org)
client for the Music group app.
-Called only by the node, never by a client (docs/musicbay.md §3): the node
+Called only by the node, never by a client (docs/MESHBAY_DESIGN.md §9.8): the node
makes the one lookup per unique release, shared by every member, and self-
paces against MusicBrainz's shared rate limit rather than letting several
members' tile requests multiply it.
@@ -70,8 +70,8 @@ def _similarity(query: str, val: str | None) -> float:
def _best_match_release(artist: str, album: str, results: list[dict]) -> tuple[dict | None, float]:
"""
Same "trust the search's own ranking" shape as tmdb.py's `_best_match`
- (§3.3 of mediacenter.md found a locally-recomputed re-rank pick a
- coincidentally closer-looking wrong result once — no reason to expect
+ (docs/MESHBAY_DESIGN.md §9.7: a locally-recomputed re-rank was found to
+ pick a coincidentally closer-looking wrong result once — no reason to expect
MusicBrainz's own scored search to fare differently under the same
treatment). MusicBrainz already returns results ordered by its own
`score`; only the top one is considered.
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index b95ee2b..ca1b87d 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -284,7 +284,7 @@ async def unpin_member(state: dict, user_id: str) -> dict:
# the key, as `set_gek` does — would make every message anyone ever sent
# permanently unreadable to everybody, which is what a plain GEK-derived
# archive key would have done on the very first `member unpin`
-# (docs/chat-sender-keys.md F4).
+# (finding F4, docs/MESHBAY_DESIGN.md §13.6).
async def _wrap_for_node(state: dict, key: bytes) -> dict:
@@ -1380,7 +1380,8 @@ async def set_node_settings(state: dict, settings: dict) -> dict:
# `webrtc._stream_sem` was assigned here for months. That attribute
# has never existed -- the pool is `ctx["_transcode_sem"]` -- so the
# `hasattr` guard was always False and the setting only ever took
- # effect on a restart, which draft-v6 §2.11 says it does not need.
+ # effect on a restart, which docs/MESHBAY_DESIGN.md §6.8 says it
+ # does not need.
if webrtc is not None:
webrtc.set_capacity(
max_concurrent_streams=updated["max_concurrent_streams"])
@@ -1539,7 +1540,7 @@ async def set_tmdb_config(state: dict, token: str | None = None,
language: str | None = None) -> dict:
"""
Whether the node uses a custom API token instead of the shipped default,
- and in what language it queries TMDB (docs/mediacenter.md §5.5).
+ and in what language it queries TMDB (docs/MESHBAY_DESIGN.md §9.7).
Node-wide (roster.py group_settings, group_id="") rather than per-group
like set_enabled_apps: the token and the shared-cache
@@ -1569,8 +1570,8 @@ async def set_tmdb_config(state: dict, token: str | None = None,
async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict:
"""
- Whether TMDB lookups run for this group at all (docs/mediacenter.md
- §5.5) — per-group, unlike set_tmdb_config above: an operator running a
+ Whether TMDB lookups run for this group at all (docs/MESHBAY_DESIGN.md
+ §9.7) — per-group, unlike set_tmdb_config above: an operator running a
real media library alongside test/demo groups on one node wants
outbound TMDB traffic (and API quota) spent for the one that needs it,
not all of them just because one process serves both.
@@ -1591,7 +1592,7 @@ async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict:
async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) -> dict:
"""
Whether MusicBrainz lookups run for this group at all
- (docs/musicbay.md §6) — per-group from the start, same reasoning as
+ (docs/MESHBAY_DESIGN.md §9.8) — per-group from the start, same reasoning as
set_tmdb_enabled: a real media-library group and a test/demo group on
one node need not share the decision to make outbound requests.
"""
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 0753400..8c9b3ef 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -16,7 +16,7 @@ Three tables:
only in the operator's hands and the invitee's.
The code is what binds a public key to an account without asking the hub
-(finding H3). See `docs/invite-pairing-v1.md`.
+(finding H3). See `docs/MESHBAY_DESIGN.md` §3.4.
"""
from __future__ import annotations
@@ -63,7 +63,7 @@ DEFAULT_DEVICE_REQUEST_TTL = 3600
_SCHEMA = """\
-- One row per DEVICE, not per person. A browser and a desktop client are two
-- keys belonging to one account, and `user_id` alone as the key made the second
--- silently overwrite the first (INSERT OR REPLACE). See docs/desktop-client-v1.md §4.
+-- silently overwrite the first (INSERT OR REPLACE). See docs/MESHBAY_DESIGN.md §3.3.
CREATE TABLE IF NOT EXISTS identities (
user_id TEXT NOT NULL,
username TEXT NOT NULL,
@@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS identities (
-- transcript binds `nonce_node` — the approving connection's handshake
-- nonce — so even a stored signature is unverifiable without it.
--
- -- This is what Tier 2 needs (docs/desktop-client-v1.md §4.8): relayed with
+ -- This is what Tier 2 needs (docs/MESHBAY_DESIGN.md §3.3): relayed with
-- the roster, it lets a member verify for themselves that a second device
-- belongs to an account whose first device they have already pinned,
-- instead of taking the node's word. Verified and discarded until
@@ -407,7 +407,7 @@ class Roster:
Every live device of every active member of one group, with the evidence
that admitted it.
- For Tier 2 (`docs/desktop-client-v1.md` §4.8), and therefore
+ For Tier 2 (`docs/MESHBAY_DESIGN.md` §3.3), and therefore
**member-visible** — unlike `list_identities`, which answers the
operator. Two consequences of that, and both are the price of the
feature rather than oversights:
@@ -750,10 +750,10 @@ class Roster:
return apps
# The TMDB credential and query language are one operator's budget, not a
- # per-group concern (docs/mediacenter.md §5.5) — stored under the
+ # per-group concern (docs/MESHBAY_DESIGN.md §9.7) — stored under the
# group_id="" sentinel, the same precedent as `roster.get_member("",
- # user_id)` authorizing the operator node-wide (desktop-client-v1.md
- # §6.3). Unset means "the shipped default token, TMDB's own default
+ # user_id)` authorizing the operator node-wide (docs/MESHBAY_DESIGN.md
+ # §6.1). Unset means "the shipped default token, TMDB's own default
# language" — the same "absent means the old behaviour" discipline
# enabled_apps already follows.
#
@@ -791,7 +791,7 @@ class Roster:
# Which folder(s) inside the group's shared roots each application uses as
# its entry point. One storage shape for every app, keyed by the app's own
# name, so adding an application needs no change here at all — that is the
- # whole point of the plugin architecture (docs/refactor-groups.md §1.6).
+ # whole point of the plugin architecture (docs/MESHBAY_DESIGN.md §9.3).
#
# Always a JSON list, even for an app that only ever wants one directory.
# Two shapes for one idea is how `video_root` (scalar) and `photo_roots`
diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
index c9623df..9a0db12 100644
--- a/packages/meshbay-node/src/meshbay_node/tmdb.py
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -1,7 +1,7 @@
"""
TMDB (themoviedb.org) client for the Videos group app.
-Called only by the node, never by a client (docs/mediacenter.md §2): the
+Called only by the node, never by a client (docs/MESHBAY_DESIGN.md §6.5): the
node holds the one credential and makes the one request per unique title,
shared by every member. Token resolution order (§5.5):
@@ -11,9 +11,9 @@ shared by every member. Token resolution order (§5.5):
exception, so a node with no token configured just serves thumbnails)
The real secret (whichever token resolves) never appears in source control:
-there is no literal fallback value in this file. See mediacenter.md's
-implementation notes on why a shipped default is a deployment concern, not
-a code concern.
+there is no literal fallback value in this file. Videos is the one
+credentialed application (docs/MESHBAY_DESIGN.md §9.7, §9.8), and which
+token ships is a deployment concern, not a code concern.
Results also come back in whatever language the operator configured
(roster.py's `tmdb_language`, e.g. "fr-FR") — one language for the whole
@@ -75,7 +75,7 @@ def _best_match(
top result is what's returned. The similarity ratio rides along purely
as a confidence signal for the caller's fallback decision.
- One narrow exception (§10.1/V9): when the top result is *not* a
+ One narrow exception (V9): when the top result is *not* a
confident textual hit (ratio < 0.6) and a `year` was requested, a
different result of that **exact** release year is preferred. TMDB
already year-filtered the search, so an entry landing on the requested
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 9ea70d8..8a5bbff 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -174,7 +174,8 @@ USER_BLOB_ACCOUNT_MAX = 8 * 1024 * 1024
_USER_BLOB_KIND_RE = re.compile(
r"^(playlists|playlist:[A-Za-z0-9_-]{1,64})$")
-# Chat link-preview results, kept in memory only (draft-v6 §2.7: the node
+# Chat link-preview results, kept in memory only (docs/MESHBAY_DESIGN.md §6.5:
+# the node
# produces enrichment on demand and keeps nothing durable — the asking device
# caches). Bounded and time-limited so a busy group cannot grow it without end
# and a page that changed its card is picked up within the hour.
@@ -212,6 +213,23 @@ _LINK_PREVIEW_RATE_WINDOW = 60.0
_LINK_PREVIEW_RATE_PER_CONN = 15
_LINK_PREVIEW_RATE_NODE = 60
+# A free-text TMDB search spends the *operator's* credential, which is rated by
+# TMDB and shared by everyone in the group: one member typing in the search box
+# can exhaust what every other member's automatic matching depends on, and the
+# operator is the one who has to notice. §6.5's rule is a bound and a named
+# adversary in the same commit; this one arrived without either.
+#
+# Per member rather than per connection, unlike link previews above: three tabs
+# is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the
+# group context so it survives a reconnect, which is the other thing a per-session
+# count cannot do.
+#
+# Generous next to what a person types — ten searches a minute is a search every
+# six seconds, sustained — and small next to a loop.
+_TMDB_SEARCH_WINDOW = 60.0
+_TMDB_SEARCH_PER_MEMBER = 10
+_TMDB_SEARCH_NODE = 30
+
# Chat limits. A message is a member-supplied write onto the operator's disk
# (`chat.db`, where retention is a manual CLI command — §6.6), relayed from there
# to every other connected member and turned into a notification for every member
@@ -327,6 +345,24 @@ SEEK_PROBE_MAX_BACKOFF_SECS = 60
# subtitle track, it is an ffmpeg that found something else to write, and it
# would sit in the media cache for ever.
SUBTITLE_MAX_BYTES = 8 * 1024 * 1024
+
+# What a whole-file audio transcode may produce. The output is AAC at 192 kbit/s,
+# so this is about forty-five minutes of source — past any track, any single
+# piece, most sets.
+#
+# The bound is the media cache's, not memory's. `put_thumb` writes one SQLite row
+# and the store is 512 MB with least-recently-used eviction, sized for what it
+# holds: thumbnails, posters, subtitle tracks, short transcodes. A three-hour
+# audiobook at this bitrate is ~260 MB — a single row that would evict most of
+# the cache to make room for itself, and be evicted in turn by the next few
+# thumbnails. It is not a size this store can hold usefully.
+#
+# It does not take away something that worked: `AUDIO_TRANSCODE_TIMEOUT_SECS` is
+# 120, so a source long enough to reach this cap was already liable to be killed
+# mid-transcode. What changes is that the refusal now says which limit was met.
+# Serving audio of that length properly is streaming the transcode rather than
+# buffering it, which is a different feature from this one.
+AUDIO_TRANSCODE_MAX_BYTES = 64 * 1024 * 1024
# Bundle fetches are served in the pre-proof window (C4). Bounded and audited
# until the native client removes remote keypair bundles entirely.
MAX_PRE_PROOF_FETCHES = 4
@@ -1004,7 +1040,7 @@ class WebRTCPeerSession:
self._ctx.get("daemon_state", {}).get("tmdb_token_customized", False)),
"tmdb_language": str(
self._ctx.get("daemon_state", {}).get("tmdb_language") or ""),
- # Music app (docs/musicbay.md §6) — same shape as the TMDB
+ # Music app (docs/MESHBAY_DESIGN.md §9.8) — same shape as the TMDB
# fields above. No language field: MusicBrainz search doesn't
# take one the way TMDB does.
"musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)),
@@ -1180,7 +1216,7 @@ class WebRTCPeerSession:
}
# The recovery-wrapped copy (MNP 0.14) rides along when present, so a
# client holding the recovery key can re-wrap it under a new
- # passphrase — docs/auth-confirm.md §4.5.
+ # passphrase — docs/MESHBAY_DESIGN.md §3.6.
if kp.get("bundle_enc_recovery"):
resp["bundle_enc_recovery"] = kp["bundle_enc_recovery"]
self._send(resp)
@@ -1564,7 +1600,7 @@ class WebRTCPeerSession:
# A person may hold several devices on one node. The authority admitting a
# new one is a key the node already pinned — never the hub, which has stored
# no user keys since 2026-08-14 and therefore cannot countersign anything.
- # See docs/desktop-client-v1.md §4.
+ # See docs/MESHBAY_DESIGN.md §3.3.
async def _do_device_request(self, msg: dict) -> None:
"""
@@ -1725,7 +1761,7 @@ class WebRTCPeerSession:
#
# This is what lets another member check for themselves that this device
# belongs to an account whose earlier device they have already pinned,
- # instead of taking the node's word (Tier 2, desktop-client-v1.md §4.8).
+ # instead of taking the node's word (Tier 2, docs/MESHBAY_DESIGN.md §3.3).
await roster.pin_identity(
user_id=self._user_id, username=self._username or "",
pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via="device",
@@ -1752,7 +1788,7 @@ class WebRTCPeerSession:
What is checked, in order: the key is a live device *of this account* in
the node's own roster (never a token claim — that is
- `per-node-identity-v1.md`'s rule), the timestamp is fresh, and the
+ `docs/MESHBAY_DESIGN.md` §3.2's rule), the timestamp is fresh, and the
signature verifies over a transcript naming this node, this group and
this connection's nonce. A key that is merely well-formed proves nothing.
@@ -1970,7 +2006,7 @@ class WebRTCPeerSession:
Admission policy for a group, read from the node's own configuration.
Never from the hub: a hub that could declare a group open would be handed
- the key to it (§3.4 of docs/invite-pairing-v1.md).
+ the key to it (docs/MESHBAY_DESIGN.md §3.4).
"""
gctx = (self._ctx.get("groups") or {}).get(group_id) or {}
return gctx.get("join_policy", "invite")
@@ -2296,9 +2332,9 @@ class WebRTCPeerSession:
# include "video" or "music" — both can make outbound third-party
# network calls (TMDB, MusicBrainz) once enabled, so an operator opts a
# group in explicitly rather than getting it for free
- # (docs/mediacenter.md §5.6, docs/musicbay.md §4.4).
- # `helloworld` is the reference implementation (docs/refactor-groups.md
- # §4.1), hidden client-side behind `?dev=1`. It is here because the
+ # (docs/MESHBAY_DESIGN.md §9.7, §9.8).
+ # `helloworld` is the reference implementation (docs/MESHBAY_DESIGN.md
+ # §9.4), hidden client-side behind `?dev=1`. It is here because the
# allow-list is server-side enforcement — a client that names an app this
# node does not know is refused — and an app the node refused could not
# demonstrate anything. This entry and the client's registry line are the
@@ -2369,7 +2405,7 @@ class WebRTCPeerSession:
per-group concern (see _do_tmdb_enabled for the per-group on/off
switch). Signed like the rest: this changes outbound third-party
network traffic the node did not have before the Videos app
- (docs/mediacenter.md §5.5, §8) — an unsigned change would let any
+ (docs/MESHBAY_DESIGN.md §9.7, §6.5) — an unsigned change would let any
member alter egress the operator never agreed to.
"""
token = msg.get("token")
@@ -2752,7 +2788,7 @@ class WebRTCPeerSession:
def _do_musicbrainz_enabled(self, msg: dict) -> None:
"""
Whether MusicBrainz lookups run for this group at all. Per-group
- from the start (docs/musicbay.md §3.2/§6) — signed like
+ from the start (docs/MESHBAY_DESIGN.md §9.8) — signed like
tmdb_enabled: it decides whether this group's members'
Music tab ever makes outbound MusicBrainz traffic.
"""
@@ -3833,13 +3869,13 @@ class WebRTCPeerSession:
self, thumb_hash: str, chunk_index: int, gek: bytes | None,
) -> dict | None:
"""
- docs/mediacenter.md §5.3: a thumbnail is served through the same
+ docs/MESHBAY_DESIGN.md §6.5: a thumbnail is served through the same
chunked file_req path as a real file, resolved against the media
cache instead of the index when the id doesn't match a file.
Sliced by `chunk_index` like a real file's chunks, not just handed
back whole: a thumbnail/poster/cover never approached CHUNK_SIZE so
this used to be equivalent to "only chunk 0 exists", but an audio
- transcode result (docs/musicbay.md, the WMA/Musepack exception) is
+ transcode result (docs/MESHBAY_DESIGN.md §9.8, the WMA/Musepack exception) is
cached in the same media_cache blob store and can be several MB —
genuinely multi-chunk, same as a file read straight off disk.
"""
@@ -3959,7 +3995,7 @@ class WebRTCPeerSession:
async def _fetch_and_cache_poster(media_cache, tmdb_client, poster_path: str | None) -> str | None:
"""
Downloads a TMDB poster/backdrop once, caches it under its own
- blake3 like a video thumbnail (docs/mediacenter.md §5.4), and
+ blake3 like a video thumbnail (docs/MESHBAY_DESIGN.md §9.7), and
returns the hash a client then fetches via the normal file_req/
chunk path (§5.3) — no client ever contacts image.tmdb.org directly.
@@ -4008,7 +4044,7 @@ class WebRTCPeerSession:
async def _do_audio_transcode_request(self, msg: dict) -> None:
"""
- docs/musicbay.md's one exception to "no node-side transcode pool":
+ docs/MESHBAY_DESIGN.md §9.8's one exception to "no node-side transcode pool":
WMA and Musepack tag/cover fine (enrich_audio.py) but decode in no
mainstream browser's <audio> element at all. Transcoded to AAC/M4A
once and cached under its own content hash — same "computed once,
@@ -4023,6 +4059,25 @@ class WebRTCPeerSession:
if not entry:
self._send({"type": "error", "detail": "File not found"})
return
+
+ # The gate `BROWSER_INCOMPATIBLE_AUDIO_EXTS` exists for, applied where it
+ # costs something. Nothing on the node read it: the player asks for these
+ # two extensions and no others, and `music-player.js` described itself as
+ # "kept in sync with the node's" constant — so the whole restriction lived
+ # in the caller, and a member's own message is not the caller.
+ #
+ # What that let through: this converts a *whole file* and holds a
+ # transcode slot shared with video streaming while it runs. Pointed at a
+ # two-hour film it spends minutes of the operator's CPU and a slot every
+ # other viewer is queued behind. `AUDIO_TRANSCODE_MAX_BYTES` catches the
+ # result, after the work; only this catches the work.
+ if Path(entry.name).suffix.lower() not in BROWSER_INCOMPATIBLE_AUDIO_EXTS:
+ self._send({
+ "type": "error",
+ "detail": "This file does not need transcoding — play it directly.",
+ "code": "transcode_not_applicable",
+ })
+ return
file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry)
if refusal is not None:
self._send({"type": "error", "detail": refusal})
@@ -4215,7 +4270,7 @@ class WebRTCPeerSession:
async def _do_music_meta_request(self, msg: dict) -> None:
"""
- docs/musicbay.md §4.3: MusicBrainz metadata for one track, resolved
+ docs/MESHBAY_DESIGN.md §9.8: MusicBrainz metadata for one track, resolved
from the group's index by its content id. Album-level (release), the
direct analogue of Videos' show-level TMDB caching: one search per
(artist, album) pair serves cover art and canonical naming to every
@@ -4295,7 +4350,7 @@ class WebRTCPeerSession:
async def _do_media_meta_request(self, msg: dict) -> None:
"""
- docs/mediacenter.md §5.4: TMDB metadata for one file, resolved from
+ docs/MESHBAY_DESIGN.md §9.7: TMDB metadata for one file, resolved from
the group's index by its content id (root+relpath the client already
knows from index_sync/index_delta identify the entry; its own `id`
is what actually names one file — never a raw filesystem path off
@@ -4320,7 +4375,7 @@ class WebRTCPeerSession:
media_cache = self._ctx.get("media_cache")
tmdb_client = self._ctx.get("tmdb_client")
- # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24):
+ # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24):
# treated exactly like "no client configured" — same silent, no-error
# degradation, since a member's Videos tab already has to handle "no
# TMDB match" as the ordinary case.
@@ -4429,7 +4484,7 @@ class WebRTCPeerSession:
return
media_cache = self._ctx.get("media_cache")
tmdb_client = self._ctx.get("tmdb_client")
- # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24) —
+ # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24) —
# same silent zero-confidence degradation as "no client configured".
if (media_cache is None or tmdb_client is None
or not self._group_ctx().get("tmdb_enabled", True)):
@@ -4467,7 +4522,7 @@ class WebRTCPeerSession:
async def _do_tmdb_search_request(self, msg: dict) -> None:
"""
Candidate TMDB matches for an operator correcting a wrong automatic
- match (docs/mediacenter.md, §V-whatever this becomes) — a plain
+ match (docs/MESHBAY_DESIGN.md §9.7, §V-whatever this becomes) — a plain
lookup, not a mutation, so unlike `tmdb_override` this needs no
admin authority: any member can see what TMDB itself would offer,
the same as the automatic search already silently does on their
@@ -4480,7 +4535,7 @@ class WebRTCPeerSession:
return
media_cache = self._ctx.get("media_cache")
tmdb_client = self._ctx.get("tmdb_client")
- # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24) —
+ # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24) —
# same silent empty-results degradation as "no client configured":
# a member with TMDB off for this group sees the same "type it in
# yourself" affordance either way, never an error.
@@ -4490,6 +4545,21 @@ class WebRTCPeerSession:
"query": query, "media_type": media_type, "results": []})
return
+ # Refused out loud, not as an empty result: "no matches" is what the
+ # client draws for an empty list, and telling somebody their film is
+ # unknown when the node simply declined to ask is a worse answer than
+ # the truth. `video-app.js`'s `runSearch` puts `detail` on screen.
+ if not self._tmdb_search_rate_ok():
+ log.info("tmdb_search_req: rate-limited (user=%s)", (self._user_id or "")[:8])
+ self._send({
+ "type": "error",
+ "detail": "Too many searches in the last minute. This spends the "
+ "operator's search quota, which everyone in the group "
+ "shares — try again shortly.",
+ "code": "tmdb_search_rate_limited",
+ })
+ return
+
raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
else await tmdb_client.search_tv_results(query))
results = []
@@ -4613,7 +4683,7 @@ class WebRTCPeerSession:
def _do_tmdb_rematch(self, msg: dict) -> None:
"""
An operator dropping one file's cached TMDB match so it re-resolves
- with the current matcher (§10.1/V13) — the one-click alternative to
+ with the current matcher (V13) — the one-click alternative to
the full search-and-pick "Fix match" flow, and reachable without
SSH (`meshbay-node video rematch` clears a whole group). Signed like
`tmdb_override`: `media_cache` is shared node-wide.
@@ -4655,7 +4725,8 @@ class WebRTCPeerSession:
async def _tmdb_search(self, tmdb_client, entry, is_show: bool):
"""
- §3.3's retry ladder — same shape for movies and shows (§10.1/V8).
+ docs/MESHBAY_DESIGN.md §9.7's scored ladder — same shape for movies
+ and shows (V8).
TMDB's own top result is still trusted per query (§3.3's last row —
no local re-ranking of *its* list); what the ladder adds is that it
*scores every candidate query* and keeps the best, instead of
@@ -4701,7 +4772,7 @@ class WebRTCPeerSession:
specific than a punctuation-normalised restatement of `primary`
(an alternative_title, a sequel variant); when it does not and the
primary hit is already decent, the remaining calls are skipped
- (§10.1/V11 — they almost never win and cost a round trip each).
+ (V11 — they almost never win and cost a round trip each).
"""
def _year_of(res: dict) -> int | None:
d = str(res.get("release_date") or res.get("first_air_date") or "")
@@ -5121,6 +5192,33 @@ class WebRTCPeerSession:
if isinstance(m.payload, bytes) else m.payload)
return row
+ def _tmdb_search_rate_ok(self) -> bool:
+ """
+ True when this search is within both the member's window and the node's;
+ records it when so, and trims both to the window on every call so neither
+ list can grow without bound.
+
+ Both are checked because they answer different questions: the member's
+ keeps one person from spending everyone's quota, and the node's keeps a
+ group of them from doing it together.
+ """
+ now = time.monotonic()
+ w = _TMDB_SEARCH_WINDOW
+ ctx = self._group_ctx()
+ by_member = ctx.setdefault("tmdb_search_hits", {})
+ who = self._user_id or ""
+ mine = [t for t in by_member.get(who, []) if now - t < w]
+ node = [t for t in self._ctx.get("tmdb_search_hits_node", []) if now - t < w]
+ if len(mine) >= _TMDB_SEARCH_PER_MEMBER or len(node) >= _TMDB_SEARCH_NODE:
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return False
+ mine.append(now)
+ node.append(now)
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return True
+
def _link_preview_rate_ok(self) -> bool:
"""
True when this preview fetch is within both the per-connection and the
@@ -5144,7 +5242,8 @@ class WebRTCPeerSession:
async def _do_link_preview_request(self, msg: dict) -> None:
"""
- Unfurl a URL a member pasted into chat (draft-v6 §2.7 enrichment rule:
+ Unfurl a URL a member pasted into chat (docs/MESHBAY_DESIGN.md §6.5's
+ enrichment rule:
the client asks, the node produces on demand, the asking device
caches — nothing durable here).
@@ -5915,13 +6014,14 @@ class WebRTCPeerSession:
# anything failing loudly: a file uploaded from a phone could not be
# deleted from the same person's laptop, and the only symptom was
# "Signature verification failed" on their own file
- # (docs/desktop-client-v1.md §4.8 A).
+ # (docs/MESHBAY_DESIGN.md §3.3).
#
# `uploader_pk` is kept, and stops being the authorization key: it is
# now the audit record of *which device* did it. Authorization is by
# account, through the roster — never through a token claim, which is
- # the protection `per-node-identity-v1.md` added and which a lookup by
- # `uploader_id` in the hub's world would give straight back.
+ # the protection per-node identity keys give (docs/MESHBAY_DESIGN.md
+ # §3.2) and which a lookup by `uploader_id` in the hub's world would
+ # give straight back.
if not (await self._verify_admin_sig(transcript, sig)
or await self._verify_uploader_sig(entry, transcript, sig)):
self._send({"type": "error", "detail": "Signature verification failed"})
@@ -5935,7 +6035,7 @@ class WebRTCPeerSession:
) -> None:
# Node operator only. A group admin who does not run the node has no
# authority over who this node admits (deny by default). Delegation is
- # designed but deferred — see §6.2 of docs/invite-pairing-v1.md.
+ # designed but deferred — see docs/MESHBAY_DESIGN.md §3.4.
if not await self._verify_admin_sig(transcript, sig):
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}")
@@ -6240,7 +6340,7 @@ class WebRTCPeerSession:
# node's answer to it, since ffmpeg re-encodes these in real time on
# any machine that can run this daemon. Reported live against an
# Xvid/MP3 .avi. `transcode_incompatible_video`'s own documentation
- # (draft-v6 §2.11) already said "HEVC *and other browser-
+ # (docs/MESHBAY_DESIGN.md §6.8) already said "HEVC *and other browser-
# incompatible video codecs*"; only HEVC was ever wired up.
can_copy = (bool(codec_str)
and raw_video_codec not in BROWSER_INCOMPATIBLE_VIDEO_CODECS)
@@ -6678,6 +6778,30 @@ def _locate(roots: RootSet, entry) -> tuple[Path | None, str | None]:
return path, None
+def _read_scratch_capped(tmp_path: Path, cap: int, what: str) -> bytes:
+ """
+ Stat ffmpeg's output, refuse it if it is too big, read it. Blocking.
+
+ Run through `asyncio.to_thread` and not `off_disk`: this file is ffmpeg's
+ own, under `tempfile.mkstemp` on the system disk, so it is not a group root
+ and there is no spun-down platter to serialise against — it only has to be
+ off the event loop. A whole transcode read inline is tens of megabytes of
+ blocking read while nothing else in the node is served.
+
+ The size is checked before the bytes are asked for, so an oversized result
+ costs a stat rather than the read *and* the memory.
+ """
+ size = tmp_path.stat().st_size
+ if size > cap:
+ raise RuntimeError(f"{what} is {size} bytes, over the {cap} cap")
+ return tmp_path.read_bytes()
+
+
+async def _discard_scratch(tmp_path: Path) -> None:
+ """Remove one of ffmpeg's temp files, off the loop like the read of it."""
+ await asyncio.to_thread(tmp_path.unlink, True)
+
+
def _append_chunk(tmp_path: Path, chunk_bytes: bytes, first: bool) -> None:
"""Add one chunk to a partial upload. Blocking; called through `off_disk`."""
with open(tmp_path, "wb" if first else "ab") as f:
@@ -6734,9 +6858,11 @@ async def _transcode_audio_to_aac(file_path: Path) -> bytes:
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg exited {proc.returncode}: {stderr.decode(errors='replace')[:300]}")
- return tmp_path.read_bytes()
+ return await asyncio.to_thread(
+ _read_scratch_capped, tmp_path, AUDIO_TRANSCODE_MAX_BYTES,
+ "transcoded audio")
finally:
- tmp_path.unlink(missing_ok=True)
+ await _discard_scratch(tmp_path)
async def _seek_lands_at(file_path: Path, t: float, map_args: list[str]) -> float | None:
@@ -6790,7 +6916,7 @@ async def _seek_lands_at(file_path: Path, t: float, map_args: list[str]) -> floa
log.warning("stream: seek probe failed at %.1fs: %r", t, e)
return None
finally:
- tmp_path.unlink(missing_ok=True)
+ await _discard_scratch(tmp_path)
text = stdout.decode(errors="replace").strip().rstrip(",")
try:
landed = float(text)
@@ -6848,10 +6974,8 @@ async def _extract_subtitle_to_webvtt(file_path: Path, ordinal: int,
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg exited {proc.returncode}: {stderr.decode(errors='replace')[:300]}")
- size = tmp_path.stat().st_size
- if size > SUBTITLE_MAX_BYTES:
- raise RuntimeError(f"subtitle track is {size} bytes, over the {SUBTITLE_MAX_BYTES} cap")
- blob = tmp_path.read_bytes()
+ blob = await asyncio.to_thread(
+ _read_scratch_capped, tmp_path, SUBTITLE_MAX_BYTES, "subtitle track")
# A WebVTT file that is only its header has no cues in it. That is what
# a bitmap track extracted by mistake produces, and what a text track
# whose stream is empty produces; either way there is nothing to show,
@@ -6861,7 +6985,7 @@ async def _extract_subtitle_to_webvtt(file_path: Path, ordinal: int,
raise RuntimeError("extracted subtitle contains no cues")
return blob
finally:
- tmp_path.unlink(missing_ok=True)
+ await _discard_scratch(tmp_path)
class WebRTCTransport:
@@ -6931,9 +7055,9 @@ class WebRTCTransport:
`ctx["_transcode_sem"]`, and `hasattr(webrtc, "_stream_sem")` is always
False. So the hot-swap was a no-op and **`max_concurrent_streams` has
never taken effect from the Node page without a restart**, contrary to
- draft-v6 §2.11. This is the one implementation, on the object that owns
- the state, so the next two caps do not each grow their own copy of the
- mistake.
+ docs/MESHBAY_DESIGN.md §6.8. This is the one implementation, on the
+ object that owns the state, so the next two caps do not each grow their
+ own copy of the mistake.
What resizing means, stated because it is a decision and not a
detail: **the new cap governs new streams; the ones already running are
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 96fa137..77491a0 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -9,7 +9,7 @@ are both clients of it. (Chat is served to browsers over MNP/WebRTC, not here.)
Served only on 127.0.0.1 — never network-exposed — and every request is gated
by a per-run session token (11.5.3) written to `<data_dir>/ui-token`. There is
no server-rendered UI: the Node page ships in the desktop client (see
-`docs/refactor-node-ui.md`).
+`docs/MESHBAY_DESIGN.md` §6.7).
"""
import logging
diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py
index 692a118..2ca0c8a 100644
--- a/packages/meshbay-node/tests/conftest.py
+++ b/packages/meshbay-node/tests/conftest.py
@@ -8,7 +8,7 @@ from meshbay_node.roots import RootSet
# ffmpeg / ffprobe run via asyncio.create_subprocess_exec, which needs the
# ProactorEventLoop — but the repo-root conftest forces the SelectorEventLoop
-# on win32 so aiortc's ICE stack works there (see devel/windows-devel.md §5).
+# on win32 so aiortc's ICE stack works there (see docs/MESHBAY_DESIGN.md §11.2).
# The two are mutually exclusive on one Windows asyncio loop; until the media
# path gets a thread-based subprocess runner, these tests can't run on win32.
needs_subprocess = pytest.mark.skipif(
@@ -39,7 +39,7 @@ def _restore_media_tool_paths():
_plat._ffmpeg_path, _plat._ffprobe_path = before
-# Windows-only gaps still to close (see devel/windows-devel.md §5/§6).
+# Windows-only gaps still to close (see docs/MESHBAY_DESIGN.md §11.2).
win32_todo = pytest.mark.skipif(
sys.platform == "win32",
reason="Windows behaviour not implemented yet (W3 / platform specifics)",
diff --git a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
index d353d27..251617c 100644
--- a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
@@ -1,12 +1,11 @@
"""
-Music-app enrichment (tag/cover extraction, docs/musicbay.md §2.1/§6) only
+Music-app enrichment (tag/cover extraction, docs/MESHBAY_DESIGN.md §9.8) only
ever runs for a group that has an audio_root configured, and only for files
under it — see daemon.py's _enrich_new_audio_entries. Same reasoning as
Videos' video_root gate (test_video_root_gates_enrichment.py), added later:
-musicbay.md's original "no root, whole shared tree" call turned out wrong
-against a real messy library, where everything under every shared folder
-got mixed together with no way to scope Music down to just the actual
-music library.
+the original "no root, whole shared tree" call turned out wrong against a
+real messy library, where everything under every shared folder got mixed
+together with no way to scope Music down to just the actual music library.
Setting or changing the folder (ops.set_app_directory) fires a one-off sweep
(_enrich_audio_root_now) of whatever it already contains — same shape as
diff --git a/packages/meshbay-node/tests/test_audio_transcode.py b/packages/meshbay-node/tests/test_audio_transcode.py
index c2f7f64..a6557f0 100644
--- a/packages/meshbay-node/tests/test_audio_transcode.py
+++ b/packages/meshbay-node/tests/test_audio_transcode.py
@@ -1,6 +1,6 @@
"""
Tests for the Music app's one exception to "no node-side transcode pool"
-(docs/musicbay.md §2.2): WMA and Musepack tag/cover fine (enrich_audio.py)
+(docs/MESHBAY_DESIGN.md §9.8): WMA and Musepack tag/cover fine (enrich_audio.py)
but decode in no mainstream browser's <audio> element at all, so
`_do_audio_transcode_request` converts to AAC/M4A on request and caches the
result — served back through the ordinary file_req/chunk path, generalized
@@ -191,3 +191,39 @@ async def test_multi_chunk_cached_blob_reassembles_correctly(tmp_path, media_cac
assert len(chunk_msgs) == total_chunks
reassembled = _reassemble_file_chunks(session.sent, gek, blob_hash)
assert reassembled == blob
+
+
+async def test_only_the_two_formats_that_need_it_are_transcoded(tmp_path, media_cache):
+ """
+ The gate that was written down and never applied.
+
+ `BROWSER_INCOMPATIBLE_AUDIO_EXTS` was read by nobody: the player asked only
+ for `.wma` and `.mpc`, and the node converted whatever file id it was given.
+ A member's own message is not the player, and this conversion is whole-file
+ while holding a transcode slot shared with video streaming — so one message
+ naming a two-hour film spends minutes of the operator's CPU and a slot every
+ other viewer is queued behind. The size cap catches the result; only this
+ catches the work.
+ """
+ clip = tmp_path / "feature.mkv"
+ clip.write_bytes(b"not really a film, and never opened")
+ session, file_id = _session(tmp_path, clip, generate_gek(), media_cache)
+
+ await session._do_audio_transcode_request({"file_id": file_id})
+
+ (msg,) = session.sent
+ assert msg["type"] == "error"
+ assert msg["code"] == "transcode_not_applicable"
+
+
+@pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
+async def test_the_two_formats_that_do_need_it_still_pass(tmp_path, media_cache):
+ """The gate must admit what it exists for; a refusal of everything is not a gate."""
+ clip = tmp_path / "clip.wma"
+ _make_wma_clip(clip)
+ session, file_id = _session(tmp_path, clip, generate_gek(), media_cache)
+
+ await session._do_audio_transcode_request({"file_id": file_id})
+
+ assert [m for m in session.sent if m.get("type") == MNP.AUDIO_TRANSCODE_RESP], (
+ f"a WMA file was refused: {session.sent}")
diff --git a/packages/meshbay-node/tests/test_bundle_store_recovery.py b/packages/meshbay-node/tests/test_bundle_store_recovery.py
index 10a3400..d649716 100644
--- a/packages/meshbay-node/tests/test_bundle_store_recovery.py
+++ b/packages/meshbay-node/tests/test_bundle_store_recovery.py
@@ -1,5 +1,5 @@
"""
-The recovery-wrapped keypair copy (docs/auth-confirm.md §4.3, MNP 0.14).
+The recovery-wrapped keypair copy (docs/MESHBAY_DESIGN.md §3.6, MNP 0.14).
`bundle_enc_recovery` is a second copy of the identity bundle wrapped under the
account's recovery key. The store has to add the column to a database that
diff --git a/packages/meshbay-node/tests/test_chat_encryption.py b/packages/meshbay-node/tests/test_chat_encryption.py
index 0401d27..e286306 100644
--- a/packages/meshbay-node/tests/test_chat_encryption.py
+++ b/packages/meshbay-node/tests/test_chat_encryption.py
@@ -1,7 +1,7 @@
"""
Chat encryption: what the node stores, what it refuses, and what survives.
-Design A of `docs/chat-sender-keys.md`. Every test here is written as "this
+Design A of `docs/MESHBAY_DESIGN.md` §4.5. Every test here is written as "this
does not work" or "this still works after X" — the regressions the plan's
register names, in the order they would bite.
diff --git a/packages/meshbay-node/tests/test_chat_history_binary.py b/packages/meshbay-node/tests/test_chat_history_binary.py
index 18efbf5..e2397a0 100644
--- a/packages/meshbay-node/tests/test_chat_history_binary.py
+++ b/packages/meshbay-node/tests/test_chat_history_binary.py
@@ -10,7 +10,7 @@ possible place to look for a wire-format error.
The fix keeps plaintext exactly where it has always been (a string in
`payload`, which older clients read) and gives ciphertext its own `ct` field.
-That way this is not a compatibility break either — `docs/chat-sender-keys.md`
+That way this is not a compatibility break either — `docs/MESHBAY_DESIGN.md` §4.5
R3.
"""
diff --git a/packages/meshbay-node/tests/test_chat_multidevice.py b/packages/meshbay-node/tests/test_chat_multidevice.py
index d718b2a..bb61285 100644
--- a/packages/meshbay-node/tests/test_chat_multidevice.py
+++ b/packages/meshbay-node/tests/test_chat_multidevice.py
@@ -11,8 +11,9 @@ what they said.
Neither shows up as an error anywhere. The first is a message that silently
reaches nobody after a second device connects and disconnects; the second is a
phone that never shows what was typed on the laptop. Both are
-`docs/chat-sender-keys.md` F7, and both are the same "keyed by account where it
-should be keyed by connection" mistake as `pin_identity`'s old INSERT OR REPLACE.
+finding F7 (`docs/MESHBAY_DESIGN.md` §13.6), and both are the same "keyed by
+account where it should be keyed by connection" mistake as `pin_identity`'s
+old INSERT OR REPLACE.
"""
from pathlib import Path
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index ce661b5..ad60b91 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -173,7 +173,7 @@ def test_the_verb_list_here_matches_the_parser():
f"VERBS above")
# The server-rendered admin UI (and its `ui` verb) were removed in
- # docs/refactor-node-ui.md phase 5. The control API stays; the browser
+ # docs/MESHBAY_DESIGN.md §6.7. The control API stays; the browser
# page does not.
assert "ui" not in declared, "the `ui` verb came back"
diff --git a/packages/meshbay-node/tests/test_device_on_connection.py b/packages/meshbay-node/tests/test_device_on_connection.py
index 3da8a8c..03ed9ca 100644
--- a/packages/meshbay-node/tests/test_device_on_connection.py
+++ b/packages/meshbay-node/tests/test_device_on_connection.py
@@ -12,7 +12,7 @@ oldest live device" and calling it the answer:
* `_admin_exec_file_delete`, which authorized deletion against **that exact
key** — so a person could not delete their own file from their other device,
and the only symptom was "Signature verification failed" on their own upload
- (`docs/desktop-client-v1.md` §4.8 A).
+ (`docs/MESHBAY_DESIGN.md` §3.3).
`device_hello` closes the first: additive, signed, refused unless the key is a
live device *of this account in the node's own roster*. The second is closed by
@@ -224,8 +224,9 @@ class _Entry:
async def test_a_second_device_can_delete_the_first_devices_upload(
tmp_path, roster):
"""
- §4.8 A. Alice uploads from her phone and deletes from her desktop. Before
- the fix this failed with "Signature verification failed" on her own file.
+ docs/MESHBAY_DESIGN.md §3.3. Alice uploads from her phone and deletes
+ from her desktop. Before the fix this failed with "Signature verification
+ failed" on her own file.
"""
sk_phone, pk_phone, pk_x_phone = _keys()
sk_desk, pk_desk, pk_x_desk = _keys()
diff --git a/packages/meshbay-node/tests/test_disk_io_off_loop.py b/packages/meshbay-node/tests/test_disk_io_off_loop.py
index 2179e66..ceaf565 100644
--- a/packages/meshbay-node/tests/test_disk_io_off_loop.py
+++ b/packages/meshbay-node/tests/test_disk_io_off_loop.py
@@ -25,6 +25,7 @@ import threading
import time
from pathlib import Path
+import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.crypto import generate_gek
from meshbay_common.protocol import MNP
@@ -252,13 +253,11 @@ def test_no_handler_touches_the_disk_on_the_loop():
on_the_disk_thread = {"_locate", "_append_chunk", "_read_and_encrypt",
"_mkdir_if_absent", "_is_empty_dir", "_rmdir_if_empty",
"safe_subdir"}
- # ffmpeg's own output, under `tempfile.mkstemp` on the system disk — not a
- # group root, so not what spins down. Listed rather than silently allowed:
- # these still read a whole transcode into memory from the loop, and the day
- # that matters it is a different measurement from this one.
- ffmpeg_scratch = {"_transcode_audio_to_aac", "_seek_lands_at",
- "_extract_subtitle_to_webvtt"}
- allowed = on_the_disk_thread | ffmpeg_scratch
+ # ffmpeg's own output goes through `_read_scratch_capped` and
+ # `_discard_scratch` on a worker thread — `asyncio.to_thread` and not
+ # `off_disk`, because a temp file is not a group root and has no platter to
+ # serialise against. Nothing is exempt here any more.
+ allowed = on_the_disk_thread | {"_read_scratch_capped"}
found = []
@@ -354,3 +353,52 @@ async def test_chunks_of_one_upload_keep_their_order_under_a_slow_disk(tmp_path,
refusals = [m for m in session.sent if m.get("type") == "error"]
assert not refusals, f"a chunk was refused: {refusals}"
assert (shared / "clip.bin").read_bytes() == b"".join(pieces)
+
+
+def test_the_scratch_read_is_only_ever_reached_on_a_thread():
+ """
+ `_read_scratch_capped` blocks by design, so the guard above allows it — and
+ that allowance is worth nothing if somebody calls it straight from a
+ handler. Passed to `asyncio.to_thread` it appears in the syntax tree as a
+ name; called inline it appears as a call, which is what this refuses.
+ """
+ tree = ast.parse(Path(webrtc_server.__file__).read_text())
+ direct = [n.lineno for n in ast.walk(tree)
+ if isinstance(n, ast.Call)
+ and isinstance(n.func, ast.Name)
+ and n.func.id == "_read_scratch_capped"]
+ assert not direct, (
+ f"_read_scratch_capped is called directly at line(s) {direct} — hand it "
+ "to `asyncio.to_thread` instead, or the cap is paid for on the loop")
+
+
+async def test_ffmpeg_output_over_the_cap_is_refused_before_it_is_read(tmp_path):
+ """
+ The stat comes first, so an oversized result costs a stat rather than the
+ read and the memory. The number in the message is the one that was measured,
+ not the cap, because an operator reading a log wants to know by how much.
+ """
+ scratch = tmp_path / "out.m4a"
+ scratch.write_bytes(b"x" * 5000)
+
+ with pytest.raises(RuntimeError, match=r"5000 bytes, over the 1024 cap"):
+ webrtc_server._read_scratch_capped(scratch, 1024, "transcoded audio")
+
+ # And under the cap it simply reads.
+ assert webrtc_server._read_scratch_capped(scratch, 8192, "x") == b"x" * 5000
+
+
+async def test_a_slow_scratch_read_does_not_stop_the_loop(tmp_path, monkeypatch):
+ """Measured like the others: the loop keeps its wake-ups during the read."""
+ scratch = tmp_path / "out.vtt"
+ scratch.write_bytes(CONTENT)
+ monkeypatch.setattr(webrtc_server, "_read_scratch_capped",
+ _slow(webrtc_server._read_scratch_capped))
+
+ with _Ticker() as ticker:
+ blob = await asyncio.to_thread(
+ webrtc_server._read_scratch_capped, scratch, 1 << 20, "subtitle track")
+
+ assert blob == CONTENT
+ assert ticker.ticks > SLOW_S / TICK_S / 2, (
+ f"the loop was blocked: {ticker.ticks} wake-ups during a {SLOW_S}s read")
diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py
index 75cf0ee..e059bbe 100644
--- a/packages/meshbay-node/tests/test_enrich.py
+++ b/packages/meshbay-node/tests/test_enrich.py
@@ -134,7 +134,7 @@ def test_synthetic_episode_number_does_not_collide_across_per_season_bonus_folde
# ── end-to-end against a real (tiny, synthetic) video file ──────────────────
# ffprobe runs via asyncio subprocess, which the win32 selector loop (forced
-# for aiortc, see devel/windows-devel.md §5) cannot spawn.
+# for aiortc, see docs/MESHBAY_DESIGN.md §11.2) cannot spawn.
pytestmark_ffmpeg = pytest.mark.skipif(
not _HAVE_FFMPEG or sys.platform == "win32",
reason="needs ffprobe installed and a ProactorEventLoop",
diff --git a/packages/meshbay-node/tests/test_enrich_photo.py b/packages/meshbay-node/tests/test_enrich_photo.py
index 7684f40..81367eb 100644
--- a/packages/meshbay-node/tests/test_enrich_photo.py
+++ b/packages/meshbay-node/tests/test_enrich_photo.py
@@ -172,7 +172,7 @@ async def test_enricher_corrects_orientation(tmp_path, media_cache):
def test_gps_is_never_read_by_this_module():
"""
- docs/photos.md §2.4/§11: GPS must never be extracted, cached, or handed
+ docs/MESHBAY_DESIGN.md §9.9: GPS must never be extracted, cached, or handed
to a caller — a location disclosure the instant it is surfaced to every
group member. Grep-based, the same discipline test_hub_address_seam.py/
test_task_lifetime.py already apply elsewhere in this codebase to a
diff --git a/packages/meshbay-node/tests/test_group_roster.py b/packages/meshbay-node/tests/test_group_roster.py
index cb9828c..a41cce2 100644
--- a/packages/meshbay-node/tests/test_group_roster.py
+++ b/packages/meshbay-node/tests/test_group_roster.py
@@ -1,9 +1,9 @@
"""
Tier 2: a member verifies another member's device for themselves.
-`docs/desktop-client-v1.md` §4.8, and `docs/chat-sender-keys.md` §13, which
-recorded why it could not ship with the encryption: **the evidence was not being
-kept.** `_do_device_add` verified the countersignature and stored only
+`docs/MESHBAY_DESIGN.md` §3.3, which records why it could not ship with the
+encryption: **the evidence was not being kept.** `_do_device_add` verified the
+countersignature and stored only
`added_by_pk` — *which* key approved, never the proof — and the transcript binds
`nonce_node`, the approving connection's handshake nonce, so even a stored
signature was unverifiable by anyone who was not on that connection.
diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py
index f12a366..dc78d8d 100644
--- a/packages/meshbay-node/tests/test_media_cache.py
+++ b/packages/meshbay-node/tests/test_media_cache.py
@@ -133,7 +133,7 @@ async def test_prune_file_also_clears_the_override_marker(cache):
assert await cache.clear_tmdb_matches(["gone"]) == 1
-# ── Music app (docs/musicbay.md §6) — file_mbid/mbid_meta ────────────────────
+# ── Music app (docs/MESHBAY_DESIGN.md §9.8) — file_mbid/mbid_meta ────────────
@pytest.mark.asyncio
async def test_file_mbid_round_trip(cache):
diff --git a/packages/meshbay-node/tests/test_musicbrainz.py b/packages/meshbay-node/tests/test_musicbrainz.py
index cff7fea..ccf7597 100644
--- a/packages/meshbay-node/tests/test_musicbrainz.py
+++ b/packages/meshbay-node/tests/test_musicbrainz.py
@@ -156,7 +156,8 @@ async def test_no_contact_configured_makes_no_request():
result, ratio = await client.search_release("Anyone", "Anything")
assert result is None
- assert calls == [], "an unidentified client must never be sent — see musicbay.md §3.1"
+ assert calls == [], ("an unidentified client must never be sent "
+ "— see docs/MESHBAY_DESIGN.md §9.8")
await client.close()
@@ -227,7 +228,7 @@ async def test_cover_art_found_returns_bytes():
@pytest.mark.asyncio
async def test_calls_are_paced_at_least_min_interval_apart():
"""
- docs/musicbay.md §3.2: the ~1 req/s courtesy limit is this node's own
+ docs/MESHBAY_DESIGN.md §9.8: the ~1 req/s courtesy limit is this node's own
job, not something the server hands out — verified by timing two calls
back to back rather than mocking the clock, so a change to the pacing
implementation that still meets the contract doesn't break this test.
diff --git a/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py
index e86a3f3..76b687e 100644
--- a/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py
+++ b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py
@@ -1,7 +1,8 @@
"""
-Whether MusicBrainz lookups run *at all* for a group — docs/musicbay.md
-§3.2/§6. Per-group from the start (unlike tmdb_enabled, which started
-node-wide and moved per-group later once the lesson was already learned).
+Whether MusicBrainz lookups run *at all* for a group —
+docs/MESHBAY_DESIGN.md §9.8. Per-group from the start (unlike tmdb_enabled,
+which started node-wide and moved per-group later once the lesson was already
+learned).
Same shape as test_tmdb_enabled_policy.py: a signed operator instruction,
scoped to self._group_id (not passed explicitly on the wire), stored via
roster.py's group_settings table under the real group_id.
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index 877994d..7b44bbe 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -24,16 +24,14 @@ NSH = CLIENT / "build" / "installer.nsh"
MAIN_JS = CLIENT / "src" / "main.js"
PRELOAD_JS = CLIENT / "src" / "preload.js"
-# The "Light" target: Electron client + UI, no bundled node. See
-# C:\Users\admin\devel\light-client.md for the evaluation this implements.
+# The "Light" target: Electron client + UI, no bundled node.
LIGHT_NSH = CLIENT / "build" / "installer-light.nsh"
LIGHT_YML = WIN / "electron-builder.light.yml"
BUILD_WIN_LIGHT = WIN / "build-win-light.ps1"
BUILD_WIN_COMMON = WIN / "build-win-common.ps1"
# The "MSIX" target: same feature set as Full, packaged for Microsoft Store
-# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for
-# the plan this implements.
+# submission instead of NSIS.
MSIX_YML = WIN / "electron-builder.msix.yml"
BUILD_WIN_MSIX = WIN / "build-win-msix.ps1"
MSIX_EXTENSIONS_XML = CLIENT / "build" / "appx-extensions.xml"
@@ -683,8 +681,7 @@ def test_ffmpeg_bundling_is_the_default_not_opt_in():
# ------------------------------------------------------------------------
-# The "Light" target: Electron client + UI, no bundled node. See
-# C:\Users\admin\devel\light-client.md for the evaluation. Weak, text-
+# The "Light" target: Electron client + UI, no bundled node. Weak, text-
# reading evidence throughout, same reasoning as the rest of this file:
# there is no electron-builder/PowerShell/NSIS runner here, and it is the
# right kind of evidence for what these guard against -- a config drifting
@@ -921,9 +918,9 @@ def test_create_group_page_falls_back_when_no_node_is_bundled():
# ------------------------------------------------------------------------
# The "MSIX" target: same feature set as Full, packaged for Microsoft Store
-# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for
-# the plan. Unlike Light, this target keeps the node runtime and both
-# service scripts -- what changes is packaging format, not what ships.
+# submission instead of NSIS. Unlike Light, this target keeps the node
+# runtime and both service scripts -- what changes is packaging format, not
+# what ships.
# Weak, text-reading evidence throughout, same reasoning as the rest of
# this file: there is no electron-builder/appx runner here either.
# ------------------------------------------------------------------------
@@ -931,9 +928,9 @@ def test_create_group_page_falls_back_when_no_node_is_bundled():
def test_msix_config_is_standalone_and_keeps_the_full_bundle():
"""
Unlike Light, MSIX ships the same node-runtime/ffmpeg/service scripts as
- Full -- an AppX install never elevating (msix-installer.md 4) is not a
- reason to drop the daemon, only to change how its two elevated
- operations get triggered (see the two tests below). --config still
+ Full -- an AppX install never elevating is not a reason to drop the
+ daemon, only to change how its two elevated operations get triggered
+ (see the two tests below). --config still
means this file is read alone (app-builder-lib's getConfig), so it
cannot silently inherit Full's package.json build.nsis or any signing
config meant for NSIS.
@@ -979,11 +976,11 @@ def test_msix_declares_no_csc_on_purpose():
No certificateFile/certificateSubjectName/certificateSha1 anywhere in
this config -- per app-builder-lib's own windowsSignToolManager.js, an
AppX target built with no certificate configured is logged as "Windows
- Store only build" and left unsigned; Microsoft signs it at publish time
- (msix-installer.md 3). Configuring a cert here would be wasted work, not
- extra safety, and would risk this target picking up whatever might one
- day be configured for Full's NSIS signing if it were ever added to this
- file instead of package.json's own build.win.
+ Store only build" and left unsigned; Microsoft signs it at publish
+ time. Configuring a cert here would be wasted work, not extra safety,
+ and would risk this target picking up whatever might one day be
+ configured for Full's NSIS signing if it were ever added to this file
+ instead of package.json's own build.win.
"""
yml = MSIX_YML.read_text(encoding="utf-8")
for forbidden in ("certificateFile", "certificateSubjectName", "certificateSha1"):
@@ -993,10 +990,10 @@ def test_msix_declares_no_csc_on_purpose():
def test_msix_declares_the_network_capabilities_firewall_ps1_would_add():
"""
Matches firewall.ps1's own rules, which are `-Profile Any` (private AND
- public network) -- msix-installer.md 8's #1 open item: whether Windows
- actually auto-exempts a full-trust packaged app on the strength of
- these declarations is unverified until sideloaded, but the declaration
- itself must at least match what the elevated NSIS path grants today, or
+ public network). Whether Windows actually auto-exempts a full-trust
+ packaged app on the strength of these declarations is unverified until
+ sideloaded, but the declaration itself must at least match what the
+ elevated NSIS path grants today, or
an MSIX install would be silently narrower than Full/Light.
"""
yml = MSIX_YML.read_text(encoding="utf-8")
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index 14285e7..2e8c183 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -7,7 +7,7 @@ or a mistake that must not work. The one to keep an eye on is
sovereignty inert as shipped, and it fails closed, so nothing else in the suite
notices if it comes back.
-See `docs/invite-pairing-v1.md`.
+See `docs/MESHBAY_DESIGN.md` §3.4.
"""
import base64
diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py
index 44d7da6..4141d13 100644
--- a/packages/meshbay-node/tests/test_season_and_search_requests.py
+++ b/packages/meshbay-node/tests/test_season_and_search_requests.py
@@ -1,6 +1,7 @@
"""
`_do_season_meta_request` (per-season TMDB overview/poster/air_date, for the
-season-tab view — docs/mediacenter.md §5.4's fix for a 3-season show whose
+season-tab view — docs/MESHBAY_DESIGN.md §9.7's per-season text, found live
+against a 3-season show whose
overview read as season-3-specific for every season) and
`_do_tmdb_search_request` (raw TMDB candidates for an operator correcting a
wrong automatic match). Neither is a signed admin op — see each handler's own
@@ -21,6 +22,12 @@ def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession:
session = WebRTCPeerSession.__new__(WebRTCPeerSession)
session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client}
session._group_id = None
+ # Set because production always has one: `_dispatch_message` refuses every
+ # message until the handshake settles `_user_id`, so a session reaching any
+ # of these handlers without it does not exist. Left out, this fixture was
+ # narrower than the node and the per-member search ceiling could not be
+ # exercised by it at all.
+ session._user_id = "u1"
session.sent = []
session._send = session.sent.append
return session
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index a229153..d6ecb71 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -1,10 +1,10 @@
"""
Phase 11.5 security regression tests.
-Each test here encodes a finding from `second-review.md`. They are negative tests:
-they assert that an attack does NOT work. The pre-11.5 code passed 209 feature
-tests while every one of these attacks succeeded — the suite only ever exercised
-happy paths, never an authorization boundary.
+Each test here encodes a finding from `docs/MESHBAY_DESIGN.md` §13.3. They are
+negative tests: they assert that an attack does NOT work. The pre-11.5 code
+passed 209 feature tests while every one of these attacks succeeded — the suite
+only ever exercised happy paths, never an authorization boundary.
If one of these starts failing, a fix has been reverted. Do not "fix" the test.
"""
@@ -447,7 +447,7 @@ def test_daemon_sets_no_global_chat_store(tmp_path):
def test_no_member_can_hand_the_node_key_material(tmp_path):
"""
- C5b, strengthened by the invite redesign (docs/invite-pairing-v1.md).
+ C5b, strengthened by the invite redesign (docs/MESHBAY_DESIGN.md §3.4).
This test used to assert that `gek_bundle_store` answered with an admin
challenge and stored nothing without an operator signature. The message is now
@@ -818,7 +818,7 @@ def test_node_control_api_serves_no_html():
H2 was stored XSS in the server-rendered admin dashboard: a member-chosen
filename, or a hub-supplied username, landed in an HTML page on the
operator's machine unescaped. That dashboard is gone
- (docs/refactor-node-ui.md phase 5) — the control API is JSON only, so there
+ (docs/MESHBAY_DESIGN.md §6.7) — the control API is JSON only, so there
is no server-side template to inject into. The Node page that replaced it
ships in the desktop client and escapes by default (Preact).
diff --git a/packages/meshbay-node/tests/test_startup_scan_enrichment.py b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
index d2cbc3e..f43c6e9 100644
--- a/packages/meshbay-node/tests/test_startup_scan_enrichment.py
+++ b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
@@ -12,8 +12,8 @@ is already running (seen by the watchdog), would trigger it. Found live
against a real library after the first restart with this feature enabled.
Enrichment only runs once a group has a video_root configured (a group
-with none set gets no TMDB/thumbnail work at all, docs/mediacenter.md
-§5.2/§10) — this test's fake roster reports the shared root itself as the
+with none set gets no TMDB/thumbnail work at all, docs/MESHBAY_DESIGN.md
+§6.5) — this test's fake roster reports the shared root itself as the
configured video_root, so the enrichment-scheduling behaviour under test
is exercised the same way a real operator's group would be.
"""
diff --git a/packages/meshbay-node/tests/test_stream_video_transcode.py b/packages/meshbay-node/tests/test_stream_video_transcode.py
index 0fd12c6..316fdce 100644
--- a/packages/meshbay-node/tests/test_stream_video_transcode.py
+++ b/packages/meshbay-node/tests/test_stream_video_transcode.py
@@ -19,8 +19,8 @@ None — there is nothing to put in `stream_init` for the client to check. Until
live against an episode rip in a `.avi` — mpeg4 video, mp3 audio, 720x404 —
which the reporting machine re-encodes at about six times playback speed. The setting
that governs it, `transcode_incompatible_video`, was documented from the start
-as covering "HEVC *and other browser-incompatible video codecs*" (draft-v6
-§2.11); only HEVC was ever wired up.
+as covering "HEVC *and other browser-incompatible video codecs*"
+(docs/MESHBAY_DESIGN.md §6.8); only HEVC was ever wired up.
These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi
test sources, ~1s), the same style as test_stream_audio_transcode.py.
diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py
index bbe9afa..2c4573a 100644
--- a/packages/meshbay-node/tests/test_title_parse.py
+++ b/packages/meshbay-node/tests/test_title_parse.py
@@ -1,6 +1,6 @@
"""
Tests for indexer/title_parse.py — synthetic filenames only, one per
-docs/mediacenter.md §3.3/§3.4 rule. The real ~1950-file library validation
+docs/MESHBAY_DESIGN.md §9.7 rule. The real ~1950-file library validation
is a manual acceptance step (§11), not something this repo's corpus holds.
"""
@@ -72,7 +72,7 @@ def test_sequel_variants_empty_when_no_trailing_digit():
assert sequel_variants("Some Movie") == []
-# ── §10.1/V10: wider sequel-index handling ──────────────────────────────────
+# ── V10: wider sequel-index handling ────────────────────────────────────────
def test_sequel_variants_roman_numeral_offers_the_digit_form():
v = sequel_variants("Old Frontier III")
@@ -81,7 +81,7 @@ def test_sequel_variants_roman_numeral_offers_the_digit_form():
def test_sequel_variants_rewrites_a_part_keyword_index_but_keeps_the_name():
- # §10.1/V14: with a "Part"/"Episode"/… keyword the bare base is
+ # V14: with a "Part"/"Episode"/… keyword the bare base is
# withheld — "Some Saga" alone collides with a franchise-origin film.
v = sequel_variants("Some Saga Part 2")
assert "Some Saga II" in v
@@ -97,7 +97,7 @@ def test_sequel_variants_reads_a_spelled_out_index():
def test_sequel_variants_saga_shape_does_not_offer_the_bare_franchise():
# Every "<Saga> Chapter <N>" was matching the franchise's first entry
- # because the bare "<Saga>" variant hit it at ratio 1.0 (§10.1/V14).
+ # because the bare "<Saga>" variant hit it at ratio 1.0 (V14).
v = sequel_variants("Some Saga Chapter III")
assert "Some Saga" not in v
assert "Some Saga 3" in v
@@ -125,7 +125,7 @@ def test_clean_query_despaces_a_folder_name_without_eating_the_last_word():
assert naive_title("Some.Show.Name") != "Some Show Name" # the trap it avoids
-# ── §10.1/V14: telling a real episode marker from a mangled number ──────────
+# ── V14: telling a real episode marker from a mangled number ────────────────
def test_has_episode_marker_accepts_real_markers():
for name in ["Some.Show.S01E08.mkv", "some.show.s1.e8.mkv",
@@ -244,7 +244,7 @@ def test_non_season_folder_name_returns_none():
assert season_from_folder_name("Some Show Name") is None
-# ── §3.4c: a bare leading episode number, guessit's 3-digit blind spot ───────
+# ── V-findings: a bare leading episode number, guessit's 3-digit blind spot ─
def test_leading_episode_number_reads_the_whole_number():
assert leading_episode_number("001 Episode's Own Title.mkv") == 1
diff --git a/packages/meshbay-node/tests/test_tmdb.py b/packages/meshbay-node/tests/test_tmdb.py
index ab452ce..299ff2a 100644
--- a/packages/meshbay-node/tests/test_tmdb.py
+++ b/packages/meshbay-node/tests/test_tmdb.py
@@ -40,7 +40,7 @@ async def test_search_movie_returns_top_result_and_confidence():
await client.close()
-# ── §10.1/V9: year-exact preference, only when the top hit is weak ──────────
+# ── V9: year-exact preference, only when the top hit is weak ────────────────
@pytest.mark.asyncio
async def test_year_exact_result_wins_when_the_top_hit_is_low_confidence():
diff --git a/packages/meshbay-node/tests/test_tmdb_config_policy.py b/packages/meshbay-node/tests/test_tmdb_config_policy.py
index c1781e3..cef4fda 100644
--- a/packages/meshbay-node/tests/test_tmdb_config_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_config_policy.py
@@ -1,6 +1,7 @@
"""
-The operator's custom TMDB API token and query language — docs/mediacenter.md
-§5.5. Same shape as test_apps_enabled_policy.py/test_scan_settings_policy.py:
+The operator's custom TMDB API token and query language —
+docs/MESHBAY_DESIGN.md §9.7. Same shape as
+test_apps_enabled_policy.py/test_scan_settings_policy.py:
a signed operator instruction, node-wide (group_id="") rather than per-group,
stored via roster.py's group_settings table.
diff --git a/packages/meshbay-node/tests/test_tmdb_enabled_policy.py b/packages/meshbay-node/tests/test_tmdb_enabled_policy.py
index 8e945ad..0a748cd 100644
--- a/packages/meshbay-node/tests/test_tmdb_enabled_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_enabled_policy.py
@@ -1,5 +1,5 @@
"""
-Whether TMDB lookups run *at all* for a group — docs/mediacenter.md §5.5.
+Whether TMDB lookups run *at all* for a group — docs/MESHBAY_DESIGN.md §9.7.
Per-group (2026-08-24 — used to be node-wide, folded into tmdb_config): a
real media-library group and a test/demo group on the same node need not
share the decision to spend TMDB quota and make outbound requests. Same
diff --git a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
index ff259c1..198d75b 100644
--- a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
@@ -1,5 +1,5 @@
"""
-`tmdb_rematch` (§10.1/V13) — an operator dropping one file's cached TMDB
+`tmdb_rematch` (V13) — an operator dropping one file's cached TMDB
match so it re-resolves with the current matcher. Signed like
`tmdb_override` (media_cache is shared node-wide); unlike `clear_file_tmdb`
it forgets a manual override marker too, since the operator is explicitly
diff --git a/packages/meshbay-node/tests/test_tmdb_search_bound.py b/packages/meshbay-node/tests/test_tmdb_search_bound.py
new file mode 100644
index 0000000..486a2c2
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_search_bound.py
@@ -0,0 +1,186 @@
+"""
+One member's typing must not spend what the whole group depends on.
+
+`tmdb_search_req` takes a member's free text and calls TMDB with the
+**operator's** credential. That credential is rated by TMDB and shared: the
+automatic matching every other member sees runs on it too. So a member holding
+down a search box — or a script doing it — degrades the library for everyone and
+costs the operator their quota, and the node had no ceiling of any kind on it.
+§6.5's standing rule is a bound and a named adversary in the same commit; this
+handler shipped with neither.
+
+Two members in every test here, which is the point: a ceiling that one person
+can exhaust for another is not a ceiling, it is a queue. The per-member window
+is what keeps them apart, and the node-wide one is what keeps them together
+from emptying the operator's quota — they answer different questions and both
+are checked.
+
+The refusal is an error rather than an empty result. An empty list is what "no
+such film" looks like, and telling somebody their film is unknown when the node
+simply declined to ask is a worse answer than the truth.
+"""
+
+import pytest
+from meshbay_node.transport import webrtc_server
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+
+
+class _FakeTmdb:
+ """Counts what would have been spent."""
+
+ def __init__(self):
+ self.calls = 0
+
+ async def search_movie_results(self, query):
+ self.calls += 1
+ return [{"id": 1, "title": "Some Saga", "release_date": "1999-01-01",
+ "poster_path": None}]
+
+ async def search_tv_results(self, query):
+ self.calls += 1
+ return []
+
+
+class _FakeMediaCache:
+ async def get_thumb_hash_by_file_id(self, _file_id):
+ return None
+
+
+@pytest.fixture
+def group():
+ """One group's context, shared by every session in it, as a node has."""
+ return {
+ "gek": b"k" * 32,
+ "tmdb_enabled": True,
+ }
+
+
+@pytest.fixture
+def node(group):
+ tmdb = _FakeTmdb()
+ ctx = {
+ "groups": {GROUP: group},
+ "media_cache": _FakeMediaCache(),
+ "tmdb_client": tmdb,
+ }
+ return ctx, tmdb
+
+
+def _member(ctx, user_id: str) -> WebRTCPeerSession:
+ s = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ s._ctx = ctx
+ s._group_id = GROUP
+ s._user_id = user_id
+ s._peer_id = user_id
+ s.sent = []
+ s._send = s.sent.append
+ s._audit = lambda *a, **k: None
+ return s
+
+
+async def _search(session, query="a film"):
+ await session._do_tmdb_search_request(
+ {"query": query, "media_type": "movie"})
+
+
+def _refusals(session):
+ return [m for m in session.sent
+ if m.get("code") == "tmdb_search_rate_limited"]
+
+
+async def test_a_member_at_the_ceiling_does_not_stop_another_one(node, monkeypatch):
+ """
+ The property a one-member test cannot state.
+
+ Alice exhausts her own window; Bob, who has typed nothing, must be served
+ exactly as if she had not been there.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 3)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100)
+ ctx, tmdb = node
+
+ alice = _member(ctx, "alice")
+ for i in range(4):
+ await _search(alice, f"film {i}")
+ assert tmdb.calls == 3, "the ceiling did not stop the fourth search"
+ assert len(_refusals(alice)) == 1
+
+ bob = _member(ctx, "bob")
+ await _search(bob, "something else")
+ assert tmdb.calls == 4
+ assert _refusals(bob) == []
+
+
+async def test_one_member_cannot_spend_the_whole_node_quota(node, monkeypatch):
+ """
+ And the other half: two members together still meet a node-wide ceiling,
+ because the operator's credential is one credential however many people
+ hold the search box down.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 100)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 2)
+ ctx, tmdb = node
+
+ alice, bob = _member(ctx, "alice"), _member(ctx, "bob")
+ await _search(alice)
+ await _search(bob)
+ await _search(bob)
+
+ assert tmdb.calls == 2
+ assert len(_refusals(bob)) == 1
+
+
+async def test_a_members_count_survives_their_reconnection(node, monkeypatch):
+ """
+ Kept in the group context, not on the session: otherwise the ceiling is one
+ reconnect wide, and a client that drops its DataChannel between searches has
+ no ceiling at all.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 2)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100)
+ ctx, tmdb = node
+
+ first = _member(ctx, "alice")
+ await _search(first, "one")
+ await _search(first, "two")
+
+ reconnected = _member(ctx, "alice") # same person, new connection
+ await _search(reconnected, "three")
+
+ assert tmdb.calls == 2, "a reconnect reset the member's window"
+ assert len(_refusals(reconnected)) == 1
+
+
+async def test_a_refusal_is_said_out_loud_and_not_drawn_as_no_matches(node, monkeypatch):
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 0)
+ ctx, _ = node
+
+ alice = _member(ctx, "alice")
+ await _search(alice)
+
+ (msg,) = alice.sent
+ assert msg["type"] == "error"
+ assert msg["code"] == "tmdb_search_rate_limited"
+ assert msg.get("results") is None, (
+ "a refusal that carries an empty result list reads as 'no such film'")
+
+
+async def test_the_windows_do_not_grow_without_bound(node, monkeypatch):
+ """
+ The lists are trimmed on every call, so the thing that bounds a member also
+ bounds what remembering them costs.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_WINDOW", 0.0)
+ ctx, tmdb = node
+
+ alice = _member(ctx, "alice")
+ for i in range(12):
+ await _search(alice, f"film {i}")
+
+ # Every entry ages out before the next call, so nothing is refused, and what
+ # is kept is the one just recorded rather than one per search ever made.
+ assert tmdb.calls == 12
+ assert len(ctx["groups"][GROUP]["tmdb_search_hits"]["alice"]) == 1
+ assert len(ctx["tmdb_search_hits_node"]) == 1
diff --git a/packages/meshbay-node/tests/test_tmdb_search_ladder.py b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
index 9865c77..dc83733 100644
--- a/packages/meshbay-node/tests/test_tmdb_search_ladder.py
+++ b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
@@ -208,7 +208,7 @@ async def test_strong_direct_match_costs_a_single_request():
assert client.calls == [("A Quiet Film", 2010)]
-# ── §10.1/V11: a decent primary hit with nothing more specific to try ──────
+# ── V11: a decent primary hit with nothing more specific to try ────────────
async def test_decent_primary_with_no_stronger_candidate_costs_one_call():
result, _, client = await _run(
@@ -222,7 +222,7 @@ async def test_decent_primary_with_no_stronger_candidate_costs_one_call():
"is not worth a second request once the primary hit is decent")
-# ── §10.1/V8: the show branch uses the same scored ladder ─────────────────
+# ── V8: the show branch uses the same scored ladder ───────────────────────
async def test_show_scored_ladder_beats_a_weak_primary_hit():
result, _, _ = await _run_show(
diff --git a/packages/meshbay-node/tests/test_transfer_settings.py b/packages/meshbay-node/tests/test_transfer_settings.py
index 7f3719d..bcfa6a8 100644
--- a/packages/meshbay-node/tests/test_transfer_settings.py
+++ b/packages/meshbay-node/tests/test_transfer_settings.py
@@ -4,7 +4,8 @@ of setting.
The **pools** are the machine's: how many transfers this node runs at once,
across every group, from `[node]` in node.toml with a roster override — the
-§2.11 pattern, changed from the Node page or the CLI, applied live.
+docs/MESHBAY_DESIGN.md §6.8 pattern, changed from the Node page or the CLI,
+applied live.
The **per-member cap** is a group's: how many one member may run at once here.
It lives on the node like every other group setting (not the hub, which would
diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
index fd0e040..d06b3f4 100644
--- a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
@@ -1,5 +1,5 @@
"""
-Videos-app enrichment (ffprobe/thumbnailing/TMDB, mediacenter.md §5.2/§10)
+Videos-app enrichment (ffprobe/thumbnailing/TMDB, docs/MESHBAY_DESIGN.md §6.5)
only ever runs for a group that has a video_root configured, and only for
files under it — see daemon.py's _enrich_new_video_entries. Burning TMDB's
rate limit and the node's CPU on an operator's whole shared index before
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 3010fe9..0839d42 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -768,7 +768,8 @@ async def test_webrtc_peer_cleanup_on_close(sk_node, sk_hub, gek, shared_dir):
browser_pc, channel, received = await _setup_peer(
transport, sk_hub, gek, "peer-cleanup")
- # Keyed per connection, not per account (docs/chat-sender-keys.md F7), so
+ # Keyed per connection, not per account (finding F7,
+ # docs/MESHBAY_DESIGN.md §13.6), so
# membership is asserted by the session object rather than by user_id —
# one account may hold several entries here.
peers = transport._ctx["_peers"]
diff --git a/packaging/caddy/meshbay.org.Caddyfile b/packaging/caddy/meshbay.org.Caddyfile
index c161202..7eaa336 100644
--- a/packaging/caddy/meshbay.org.Caddyfile
+++ b/packaging/caddy/meshbay.org.Caddyfile
@@ -11,10 +11,10 @@
# /sw.js the service worker MUST stay at the root or its scope stops
# covering the pages it intercepts downloads for. A 404 here
# silently breaks streamed downloads on Firefox and Safari.
-# /a/<hash>/* the versioned module graph. The old snippet in
-# devel-phases-next.md proxied `/style.css` and `/*.js`, which
-# matches neither this prefix nor /locales/*.js — it predates
-# asset versioning and would 404 the entire bundle.
+# /a/<hash>/* the versioned module graph. The old snippet proxied
+# `/style.css` and `/*.js`, which matches neither this prefix
+# nor /locales/*.js — it predates asset versioning and would
+# 404 the entire bundle.
# /style.css old bookmarks, still served unversioned by the hub.
#
# Deployment: the site is NOT pushed by the hub deploy procedure. Sync it
diff --git a/packaging/conf/hub.toml.example b/packaging/conf/hub.toml.example
index b5f3830..5407436 100644
--- a/packaging/conf/hub.toml.example
+++ b/packaging/conf/hub.toml.example
@@ -51,7 +51,7 @@ access_token_ttl = 14400 # 4 h
# reCAPTCHA v2 on registration and password reset, so no mail is ever sent
# before a human has been seen. Absent, or either key empty, disables it
# entirely — which is right for development and for a hub nobody can reach.
-# See docs/captcha.md.
+# See docs/MESHBAY_DESIGN.md §7.7.
site_key = ""
secret_key = ""
@@ -80,7 +80,7 @@ allowed_hosts = []
# page or somebody else's Electron application look identical from here. The
# captcha still has to be solved per token; what is given up is the origin
# restriction for those clients. Leave it off unless you ship the desktop
-# client. See docs/captcha.md §6.
+# client. See docs/MESHBAY_DESIGN.md §7.7.
allow_unattributed_host = false
diff --git a/packaging/systemd/meshbay-node-user.service b/packaging/systemd/meshbay-node-user.service
index feb415b..0c60671 100644
--- a/packaging/systemd/meshbay-node-user.service
+++ b/packaging/systemd/meshbay-node-user.service
@@ -27,11 +27,11 @@ Type=simple
# the node authenticates to the hub with an Ed25519 signature (NS7).
# MESHBAY_TMDB_DEFAULT_TOKEN (Videos app, optional) also lives here — never
# in source. Absent means TMDB lookups are inert until the operator sets one
-# here or configures a custom token in Settings (docs/mediacenter.md §5.5).
+# here or configures a custom token in Settings (docs/MESHBAY_DESIGN.md §9.7).
# MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT (Music app, optional) lives here too,
# same reasoning — not a credential (MusicBrainz needs none), but still
# never a literal value in source: it's a personal contact address sent
-# as-is in every request's User-Agent (docs/musicbay.md §3.2). Absent means
+# as-is in every request's User-Agent (docs/MESHBAY_DESIGN.md §9.8). Absent means
# MusicBrainz lookups are inert until the operator sets one here or
# configures a contact in Settings.
EnvironmentFile=-%h/.config/meshbay/node.env
diff --git a/packaging/systemd/meshbay-node.service b/packaging/systemd/meshbay-node.service
index ad4dc40..aa720b5 100644
--- a/packaging/systemd/meshbay-node.service
+++ b/packaging/systemd/meshbay-node.service
@@ -15,13 +15,13 @@ WorkingDirectory=%h
# Secrets: MESHBAY_PASSWORD (hub login), MESHBAY_UNLOCK_KEY (keystore),
# MESHBAY_TMDB_DEFAULT_TOKEN (Videos app — optional, no literal default
# ships in source; absent means TMDB lookups are inert until the operator
-# sets one here or configures a custom token in Settings, docs/mediacenter.md §5.5).
-# MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT (Music app — optional, no literal
-# default ships in source either; not a credential, but still never a
-# literal value in git — it's a personal contact address sent as-is in
-# every MusicBrainz request's User-Agent, docs/musicbay.md §3.2). Absent
-# means MusicBrainz lookups are inert until the operator sets one here or
-# configures a contact in Settings.
+# sets one here or configures a custom token in Settings,
+# docs/MESHBAY_DESIGN.md §9.7). MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT (Music app
+# — optional, no literal default ships in source either; not a credential, but
+# still never a literal value in git — it's a personal contact address sent
+# as-is in every MusicBrainz request's User-Agent, docs/MESHBAY_DESIGN.md
+# §9.8). Absent means MusicBrainz lookups are inert until the operator sets one
+# here or configures a contact in Settings.
EnvironmentFile=-%h/.config/meshbay/node.env
ExecStart=/opt/meshbay-common/venv/bin/meshbay-node --config %h/.config/meshbay/node.toml
diff --git a/packaging/win/build-win-light.ps1 b/packaging/win/build-win-light.ps1
index 5a8a06e..8b2d807 100644
--- a/packaging/win/build-win-light.ps1
+++ b/packaging/win/build-win-light.ps1
@@ -6,12 +6,11 @@
.DESCRIPTION
The counterpart of build-win.ps1 (Full) for anyone who only wants to
*use* MeshBay -- join groups, chat, browse, download, stream, cast --
- without ever hosting content from this machine. See
- C:\Users\admin\devel\light-client.md for the evaluation this
- implements: a member never needs a local node to begin with (identity
- keys are per node -- the *host's* node, not the joiner's), so Light is
- the existing browser-only usage pattern wrapped in the Electron shell,
- minus the frozen meshbay-node.exe / ffmpeg / autostart bundle.
+ without ever hosting content from this machine. A member never needs a
+ local node to begin with (identity keys are per node -- the *host's*
+ node, not the joiner's), so Light is the existing browser-only usage
+ pattern wrapped in the Electron shell, minus the frozen
+ meshbay-node.exe / ffmpeg / autostart bundle.
Steps 1-4 are identical to build-win.ps1 (build-win-common.ps1). Step 5,
PyInstaller freezing a node runtime, does not happen at all -- that is
diff --git a/packaging/win/build-win-msix.ps1 b/packaging/win/build-win-msix.ps1
index 9f7f667..2aeb2b8 100644
--- a/packaging/win/build-win-msix.ps1
+++ b/packaging/win/build-win-msix.ps1
@@ -4,15 +4,14 @@
+ ffmpeg), packaged for Microsoft Store submission instead of NSIS.
.DESCRIPTION
- See C:\Users\admin\devel\msix-installer.md for the plan this implements.
Unlike Light, this target does NOT drop anything from Full's feature
set -- it exists because Store certification of the NSIS "MSI/EXE"
submission failed for a reason MSIX sidesteps entirely (an unsigned,
internet-downloaded installer never gets a chance to run under
- Microsoft's own unattended validation bot; see msix-installer.md §2),
- not because the bundled node/service-mode/autostart machinery needed
- removing. The one real change is *when* the two elevated operations
- (firewall rule, service-mode install) can happen: an AppX/MSIX install
+ Microsoft's own unattended validation bot), not because the bundled
+ node/service-mode/autostart machinery needed removing. The one real
+ change is *when* the two elevated operations (firewall rule,
+ service-mode install) can happen: an AppX/MSIX install
never elevates, so neither can run at install time the way
build/installer.nsh's customInstall macro does. Both already have an
elevation path that does not depend on the installer at all --
@@ -126,7 +125,6 @@ if ($pkg) {
Write-Host "OK package: $($pkg.FullName)" -ForegroundColor Green
Write-Host (" ({0:N0} MB)" -f ($pkg.Length / 1MB))
Write-Host " unsigned by design -- Microsoft signs it at publish time" -ForegroundColor DarkGray
- Write-Host " (msix-installer.md 3)." -ForegroundColor DarkGray
}
else {
Write-Host "!! no *.appx found in $Client\dist-msix" -ForegroundColor Red
diff --git a/packaging/win/electron-builder.light.yml b/packaging/win/electron-builder.light.yml
index 155ffa6..502a3c5 100644
--- a/packaging/win/electron-builder.light.yml
+++ b/packaging/win/electron-builder.light.yml
@@ -10,9 +10,6 @@
# Invoked from packages/meshbay-client (see build-win-light.ps1), so every
# relative path below resolves the same way package.json's `build` field's
# already do.
-#
-# See C:\Users\admin\devel\light-client.md for the evaluation this
-# implements.
appId: org.meshbay.client.light
productName: MeshBay Light
diff --git a/packaging/win/electron-builder.msix.yml b/packaging/win/electron-builder.msix.yml
index 5e45598..5460267 100644
--- a/packaging/win/electron-builder.msix.yml
+++ b/packaging/win/electron-builder.msix.yml
@@ -1,9 +1,8 @@
# Standalone electron-builder config for the "MSIX" Windows target: same
# feature set as Full (bundled node + ffmpeg), packaged for Microsoft Store
-# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for
-# the plan this implements -- read that first, especially §4 (why the
-# installer can carry none of installer.nsh's elevation logic: an AppX/MSIX
-# install never elevates, by design) and §8 (what is still unverified here).
+# submission instead of NSIS. The package carries none of installer.nsh's
+# elevation logic: an AppX/MSIX install never elevates, by design. What is
+# still unverified here is called out at each declaration below.
#
# Deliberately NOT layered onto package.json's `build` field, same reasoning
# as electron-builder.light.yml: --config reads ONLY this file, so nothing
@@ -22,8 +21,8 @@
# windowsSignToolManager.js (computePublisherName), an AppX target built
# with no certificate configured is logged as "Windows Store only build" and
# left unsigned, with `publisher` written into the manifest as-is. Microsoft
-# signs the package itself at publish time (msix-installer.md §3) -- signing
-# it here first would be pointless work, not extra safety.
+# signs the package itself at publish time -- signing it here first would be
+# pointless work, not extra safety.
appId: org.meshbay.client
productName: MeshBay
@@ -102,9 +101,9 @@ appx:
# firewall.ps1's own rules, which are `-Profile Any` (private AND public
# network). Whether Windows Firewall actually auto-exempts a full-trust
# packaged app on the strength of these declarations -- eliminating the
- # elevation firewall.ps1 exists for entirely -- is msix-installer.md §8's
- # #1 open item: verify live before relying on it, the declaration alone
- # only proves the manifest is well-formed.
+ # elevation firewall.ps1 exists for entirely -- is an open item: verify
+ # live before relying on it, the declaration alone only proves the
+ # manifest is well-formed.
capabilities:
- internetClientServer
- privateNetworkClientServer
@@ -116,7 +115,6 @@ appx:
# switch to point it at a different bundled exe). build/appx-extensions.xml
# declares that extension by hand for exactly this reason. Whether
# Windows actually launches a *non-primary* bundled exe through this
- # mechanism is the other open item in msix-installer.md §8 -- untested
- # until sideloaded.
+ # mechanism is the other open item -- untested until sideloaded.
customExtensionsPath: build/appx-extensions.xml
showNameOnTiles: false