aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
Commit message (Collapse)AuthorAgeFilesLines
* fix(node): make max_concurrent_streams take effect without a restartChristophe Besson2026-09-081-3/+60
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ops.set_node_settings` hot-swapped the stream pool by assigning `webrtc._stream_sem`. That attribute has never existed on WebRTCTransport — the pool is `ctx["_transcode_sem"]` — so `hasattr(webrtc, '_stream_sem')` was always False and the branch never ran. The setting was accepted, written to roster.db and node.toml, and applied only on the next restart, which is exactly what draft-v6 §2.11 says it does not need. An operator lowering the cap on a struggling machine, or raising it after "Server busy", saw nothing happen and had no way to find out why. `WebRTCTransport.set_capacity()` is the one implementation, on the object that owns the state, so the download and upload caps the transfer-slots plan adds next do not each grow their own copy of the mistake. Resizing has semantics worth stating: the new cap governs new streams and never interrupts one that is running, because a slot is held for the length of a film and lowering a number must not take somebody's film away. The replacement pool is built with the permits that remain (`new - in_flight`, floored at zero) — a full set would briefly allow more concurrent viewers than either the old cap or the new one. That needs a count of slots in use, so `_stream_video` now maintains one instead of the code reading the semaphore's private `_value`: a number this code keeps itself survives the semaphore object being replaced underneath it, and the same counter makes the "N of M in use" log lines mean something. test_stream_capacity.py drives the real transport and the real `_stream_video`; `test_ops_calls_the_real_mechanism` fails if the dead attribute comes back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(chat): Tier 2 — a member verifies another member's device itselfChristophe Besson2026-09-071-2/+62
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Chat messages have been signed by the sending device since MNP 2.0, but a reader had no way to know that the device belonged to the account the node named: the signature proved *a device*, and `sender_id` was still the node's word. This closes that for any account a client has already seen. **What was blocking it was not effort — the evidence was not being kept.** `_do_device_add` verified the countersignature that admits a second device and stored only `added_by_pk`: *which* key approved, never the proof. And `device_add_transcript` binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who had not been on that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added before the migration's early return — which fires on every roster widened since 2026-08-18, i.e. all of them, so putting them inside it would have meant they never arrived. `group_roster_req`/`resp` relays, sealed under a new groupbox purpose and answered to **any member of the group**, every live device of every active member with the evidence that admitted it. The node decides nothing: it hands over evidence and the client walks the chain from each account's root outwards (`_verifyRoster`). That is deliberate — the node is the party the property holds against, so it is not asked to assert trust. Two holes the tests caught while this was being built: - "no signature" was being treated as a trust root, so a node that writes the roster could put any key in an account's row and have it laundered straight into the verified set. A root is a device that names **no** countersigner. - pinning only the verified subset at first sight raised "key changed" on legitimate second devices whose countersignature predates this change. First sight pins everything the node says, because that is what trust-on-first-use means and an alarm that fires on normal events stops being read. The property, and it must not be rounded up: **once a client has seen an account, a node that later substitutes a key for it is detected. Nothing is gained at first sight**, where there is nothing to compare against — the same boundary `per-node-identity-v1.md` draws, unmoved. The cost, stated because it is real: the roster is member-visible, so every member learns how many devices the others hold and their public keys. It stays inside the group, the hub is not involved, and it is scoped per group. A member who cannot see the keys cannot check them. User-visible surface: one notice, "this account is using a key you have not seen before", in ten languages. Nothing else. 16 tests — 7 on the node (the evidence is stored, it verifies from the roster alone, a fabricated device carries none, another group's members are not disclosed), 9 running the shipped `_verifyRoster` under node against rosters built by the shipped Python: a chain of three in any order, a signature by the wrong key, one for another node, one for another account, and two fabricated devices signing each other admitting nothing. Tier 3 (operator-signed roster attestation) stays deferred, with nothing depending on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
* Merge origin/main into the chat encryption workChristophe Besson2026-09-073-233/+171
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Both sides landed a breaking MNP change and both called it 2.0, which is right: the sealed upload, the removal of `stream_seg` and mandatory chat encryption share one flag day. They are recorded as one version in `__init__.py` rather than as a race between two. The resolutions that were decisions rather than mechanics: * **`MNP_MIN_SUPPORTED` moves to "2.0".** The sealed upload alone was a *confined* break — a 1.x peer could still connect, browse, download, stream and chat, with only its uploads refused by `upload_not_sealed` — so the floor deliberately stayed at "1.0". Mandatory chat encryption ends that confinement: a 1.x peer can neither produce a sealed chat message nor read one, so it would connect, look fine, and be unable to say anything. Refusing it at the handshake is the honest form. The per-message `upload_not_sealed` path is untouched and still right if the floor is ever lowered. * **`sendChat` throws on an `error` reply**, from origin, applied to the sealed send. It matters more after this change, not less: the node now refuses a stale epoch, a malformed envelope and a device claim that is not the connection's own, so there are three new ways for a message to be rejected and none of them may look like a message that was sent. * **`req_id` supersedes the per-type routing** this branch added for `chat_keys_resp` and `device_hello_ack`. Both blocks are kept beside the existing `chat_hist_resp` one, for the same stated reason — a node too old to stamp — and their comments no longer claim to be the mechanism that closes the class. `req_id` is. * **`chat_send_probe.py` is rebuilt on origin's structure**, not beside it: two scenarios, a stub that stamps `req_id`, `music_meta_req` as the older pending request. The encrypted path is layered on — a real Ed25519 device key generated in the page, and a `chat_keys_resp` sealed by the shipped Python, because a payload the page built itself would prove only that the page agrees with the page. * **`test_reply_correlation.py` now sends a sealed message.** Its subject is which of the two messages leaving that handler carries the id; plaintext chat was only the fixture, and the node refuses one now. * `groupbox` keeps both new purposes (`upload`, `chat_keys`); `protocol.py` keeps origin's removal of `STREAM_SEGMENT` and this branch's correction of the "Double Ratchet message" comment on `CHAT_MESSAGE`, which was wrong when it was written and is wrong differently now. Full suite on the merged tree: 1993 passed, 11 failed — the same 11 that fail on a pristine checkout (2 Windows service tests, 1 apps-enabled policy, 7 transcode tests that pass in isolation, and the WebRTC invite test that hangs on its own). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
| * feat(mnp)!: seal the upload under the group keyChristophe Besson2026-09-071-68/+105
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Downloads have been encrypted under a GEK-derived key since the beginning: `file_chunk` and `stream_data` both go through `chunk_ciphertext`. Uploads never were. `file_upload` carried the filename and the raw bytes in plain msgpack, and `file_upload_ack` carried the name the node stored them under — so the same file was ciphertext leaving a node and plaintext arriving at one. There was no threat model behind that asymmetry. Both halves now travel sealed under a third groupbox purpose, HKDF(GEK, info="meshbay:upload:v1"). The filename, the destination folder and the bytes are all inside the seal; only `upload_id` and `chunk_index` stay in clear, because the node routes and orders on them before it can decrypt. This direction seals *towards* the node — it holds the GEK for its own group — and it opens the payload before it picks a destination or touches the disk. What that forced, and why none of it is optional: - `filename` was the correlation key on both sides. It cannot be: matching an ack to its request by name would hand back exactly what the seal hides. `upload_id` replaces it — client-drawn, opaque to the node, unique within a connection, never an authorization input. The property it guarded (one refusal fails one upload, not every upload in flight) is unchanged. - Refusals can no longer quote what they refused. `No directory named 'X'` becomes `No such directory in this group` plus the `code` that was already there; the client knows what it sent. - No plaintext fallback. A path that still accepts plaintext is not a sealed path, so an unsealed `file_upload` is refused with `upload_not_sealed`. Hardened while here, because what comes out of a seal is authenticated but not validated — a member can seal anything: `filename` and `data` have their types checked before any upload state is created, and `chunk_index`/`total_chunks`, which are outside the seal by necessity, can no longer raise where a refusal was meant. Tests. `test_upload_sealed.py` pins the node half: nothing identifying on the wire, tamper/wrong-key/wrong-group all refused with nothing written, and multi-chunk reassembly unchanged. `test_upload_seal_client.py` drives the shipped `uploadFile` over the shipped `crypto.js` under node and feeds its real frames to the real `_do_file_upload` — the file lands intact, and the ack the node actually produced comes back with the name it chose for a collision, which is the half a source-reading test cannot see. Both upload purposes join the JS/Python groupbox parity vectors. BREAKING CHANGE: MNP 2.0. `file_upload`/`file_upload_ack` change shape on the wire every deployed client speaks, which is MAJOR by the same rule 1.0 was — but the break is confined to uploads. `MNP_MIN_SUPPORTED` stays at "1.0", so a 1.x peer still connects, browses, downloads, streams and chats; only its uploads are refused, with a message saying which side is old. The client checks the node's version before sending a chunk, so neither side meets this as a timeout. This is the version negotiation shipped in 1.0 earning its keep: 1.0 cost a flag day, 2.0 costs a refusal code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
| * refactor(mnp)!: remove stream_seg, the last unencrypted content messageChristophe Besson2026-09-073-162/+0
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `stream_seg` answered with an MPEG-TS segment as base64 with no encryption at all — the one message on the content plane that never went through a GEK-derived key. Live on both transports, answering any authenticated member. It predates `stream_data`, which does the same job properly (`chunk_ciphertext`, keyed per segment, AES-256-GCM) and has since Phase 12. Its only browser caller, `fetchStreamSegment`, was defined and never once invoked — a plaintext media endpoint with no client. Removed rather than repaired. Gone with it: `_extract_segment` and the ffmpeg semaphore in quic_server, the `fetch_stream_segment` QUIC client method, and `_b64decode` in transport.js, which had no other caller. The H6 regression test lived on this handler — it pinned `_do_stream_segment_async` to a coroutine so `subprocess.run` could not stall the event loop for thirty seconds per request. It is replaced by the property that outlives the handler: no transport carries media outside an AEAD, asserted on `stream_seg` and `data_b64` across all three transport modules. The half of H6 that survives — the live streaming path still spawns ffmpeg — keeps its own test. BREAKING CHANGE: `stream_seg` is no longer answered on either transport. No shipping client sends it. Recorded as part of MNP 2.0, whose other half — the sealed upload — carries the version bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
| * fix(mnp): give a reply an id, so it stops being routed by luckChristophe Besson2026-09-071-1/+61
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | MNP carried no correlation id. A reply named its own type and nothing else, so a client with more than one request in flight worked out which one a message answered from the message itself — and for the replies that name nothing it could not. `_dispatch` fell through to matching by arrival order, which is a guess. `_sendAndWait` had the right value all along: it keys `_pending` by `this._seqId++` and never put it on the wire. The guess fails asymmetrically, which is why it hid. The victim is not the request that was answered wrongly — it is the unrelated one that now waits out its own 30s timeout for a reply already delivered elsewhere. Live on 2026-09-06: five `music_meta_req` sat pending for over 100 seconds behind a failing MusicBrainz, and a `device_list_result` was handed to one of them. The composer is disabled while a send is in flight, so a chat message whose reply went astray the same way left the Chat tab looking frozen for thirty seconds, then unfroze on its own. The `ack` half of this was fixed on 2026-08-30 by matching on request type. That closed the instance and left the class open: a refusal has no type to match on either, and `_dispatch_message`'s catch-all answers every unforeseen failure with `{"type": "error", "detail": "Request failed"}` — 238 of this module's 240 error sends name nothing at all. `req_id` now rides on the request and comes back on the reply. On the node it is published for the whole handler in a ContextVar and stamped by `_send`: a parameter would have meant threading an argument through all 240 send sites, and asyncio copies the context into a task, so a handler that `_spawn`s its real work still answers under the right id. It is never stamped on a broadcast — those answer nothing, and the owner check in `_send` is what keeps a chat broadcast or an index push from reaching another peer looking like a reply. On the client, `_dispatch` resolves on `req_id` first and the arrival-order fallback is gone the moment a node proves it stamps (`_correlates`, armed by the handshake's own reply). The fallback stays for an MNP 1.0 node, unchanged and no wider: there it is the only thing there is, and removing it would leave device_list_result, join_result and the handshake replies reaching nobody. Two things fall out. `sendChat` refuses an `error` reply like every other request in the file — it returned it as success, which did not matter while a refusal reached the wrong caller anyway and would now show a rejected message as sent. And `_group_ctx` uses `.get`: a reload pops a removed group while sessions connected to it are open, and every request they had left raised KeyError into that same catch-all. Sealed index messages are the one exception to the fast path. They cannot be handed over until they are opened, which is asynchronous while `_dispatch` is not — resolving on the id alone gave `fetchIndex` the envelope and skipped `onIndexSync` entirely. Caught by extending `index_seal_probe.mjs` to stamp a reply the way a current node does, after the hub suite passed over it: the probe built its own frames and had never seen one. Tests, all failing before and passing after: `test_chat_send.py` drives the real ChatPanel over the real transport for both shapes of reply with an older request pending (3 of its 6 are new, and the 3 for `ack` pass either way, so it discriminates); `test_reply_correlation.py` pins the node's half — the refusals that name nothing else, the broadcast that must not be stamped, and a late reply from a spawned task answering under its own id rather than the most recent request's. Full suite: 1897 passed, same 11 pre-existing failures as before. QUIC keeps its own dispatch and is not stamped. It is disabled by default and no browser request reaches it, but the asymmetry is real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dn1xYx9uT69mCB6UDvyKAN
* | feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)Christophe Besson2026-09-072-57/+529
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Chat messages are sealed with AES-256-GCM under a key derived per group, per epoch, per *device*, and signed over the ciphertext with the device key the node pinned. The node relays and archives; it cannot read a message. There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a 1.x peer is refused at the handshake with `version_too_old` rather than admitted and then unable to speak. An opt-in flag was designed and rejected: every node is a test node, so it would have bought nothing and left a plaintext branch reachable — C6's lesson one feature later. A test reads the source and refuses any code that consults a `chat_encrypted` setting. Not Sender Keys, and `senderkeys.py` is now documented as unused. With distribution under the group key and a node that serves history to devices which were not present, the node must retain each chain's earliest key, and a chain key at iteration i yields every message key from i on by pure HKDF — forward secrecy is zero either way. What the ratchet was left buying was stateful client code with silent failure modes, three of them reproduced: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound. The reasoning is in docs/chat-sender-keys.md, which is the specification and the decision record. Epochs, not rotation: the epoch key is wrapped under the group key at delivery and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived archive key would have made every message ever sent unreadable on the first `member unpin`, which is the documented step after removing a member. A new epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs are kept and still delivered, so history stays readable to everyone who could already read it, and nothing anywhere deletes one. Three prerequisites this needed, each a live defect on its own: * The peer registry was keyed by user_id, so one account's second device evicted the first and the broadcast skipped recipients by account — a person's phone never saw what they typed on their laptop. * The handshake authenticated an account, never a device. `device_hello` (additive, signed, refused unless the key is a live device of this account in the node's own roster) is what lets the node refuse a member claiming somebody else's key. * `_admin_exec_file_delete` authorized against the exact uploading key, so device linking had already broken deleting your own file from your other device. It now authorizes against any non-revoked device of `uploader_id`. Found by driving the real panel over the real transport, not by reading source: `chat_keys_resp` was routed by arrival order and handed to an unanswered `media_meta_req` — the original frozen-tab defect in a message type that did not exist when that probe was written. And `_asText` had been deleted with an unrelated helper beside it; its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. Existing node data is migrated by QE/migration/migrate_chat_encryption.py (not versioned, per the QE rule), run with the node stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
* fix(node): remove --upload-dir rather than document itChristophe Besson2026-09-071-3/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | Caught in review, and the review was right. The previous commit documented the flag as deprecated so that `--help` and the man page would agree. That solved the wrong problem: the flag contradicts the model this whole refactor exists to establish, and the coherent answer was to delete it. It wrote `upload_dir` into a *brand-new* `[[groups]]` block, and `GroupConfig.__post_init__` reads that key by forcing every other root read-only and appending that path as the one writable one. So `group add --dir X --writable --upload-dir Y` silently made X read-only — two mechanisms deciding which directories accept uploads, one of them invisible, in a group created after the model that replaced it. Gone from the CLI, from `ops.attach_group`, from the loopback API and from the MNP `group_attach` payload, which now carries `writable` instead. The *read* path in `config.py` is deliberately untouched: an existing node.toml using `upload_dir` must keep working, and that is the only legitimate use left. The man page says so under the config key, and no longer lists an option. The test that guarded the deprecation wording now guards its absence — and earned itself immediately by finding a `group add` usage string still offering the flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): HelloWorld, the reference applicationChristophe Besson2026-09-071-1/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every other test of the plugin architecture reads source for the *absence* of app names. That proves nobody wrote a special case for Videos; it cannot prove a genuinely new application works, because there was no new application. This is one. It stores directories, appears as a tab, has a settings pane and lists files, and the node has never heard its name outside a single allow-list entry. Two files and one registry line, which is the claim `docs/refactor-groups.md` §4.1 makes. It ships hidden behind `?dev=1` (`dev: true` in the registry, the same opt-in shape as transport.js's `?trace=1`). Registering it normally would put a toy app in every operator's group; not registering it would prove nothing, since registration is exactly what is claimed to be sufficient. **Adding it found two places where the claim was nearly true rather than true, and both are fixed by making the code less app-specific:** `group-settings.js` fell back to the whole registry when a group had no `enabled_apps` yet — which would have turned a hidden app on for everyone. It asks `availableApps()` now. `group-page.js` wrote out `videoDirectories` / `musicDirectories` / `photoDirectories` by hand, so a fifth app would have needed that file edited. It derives `<key>Directories` from the registry. Neither was found by reading; both were found by adding the app, which is the whole reason it exists. Verified in a real Electron window as well as by the tests: hidden by default, present with the flag, offered its own settings section, and listing exactly the files under its configured folder and its subfolders — not the ones beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): bring back New folder, icon-only — and close the hole it openedChristophe Besson2026-09-061-0/+22
| | | | | | | | | | | | | | | | | | | | | | | | | The control was hidden and its `canCreateDir` left computed and unused. It is back in the Files toolbar as an icon: the toolbar already carries one labelled primary action, and a second beside it competes for the width the breadcrumb trail needs. The name is in `title` *and* `aria-label` — a title is invisible to a screen reader on a button with no text, so an icon-only control without both is simply unnamed for anyone not reading with their eyes. Its gate changes. It required `isNodeAdmin`, which contradicted the node's own rule — "making a directory is not a privileged act; a member who can add a file can organise where it goes" — and hid the control from everyone who could have used it. It now follows the Upload button: a writable root, and not at the top of a group, where the level is the set of roots rather than a directory on anyone's disk. Restoring it surfaced a real gap. `_do_dir_create` never learned about RO/RW: `_do_file_upload` gained the `writable` check with the model and this one did not, so a member refused a file in a published library could still leave empty directories all through it, and could write to a drive mid-eject. Read-only has to mean read-only for every way of writing, not just for files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix: a root change reaches every client without a page reloadChristophe Besson2026-09-061-1/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The directory table travelled on `index_sync` alone — a *full* index, which the node only ever sends on request. Every ongoing change went out as an `index_delta`, which carried files and nothing else. So the message that says "something changed" was the one message that could not say a root had. The acks hid it: `root_add_ack` and friends broadcast the new table to whoever is connected, so the common cases looked right. What that could not cover was the operator's own client, where the ack landed and was then overwritten — the table calls `onRefreshIndex` after an add, that fetch returns the set from *before* the node's reload (fire-and-forget, because a rescan is minutes on a real library), and `applyIndex` writes it over what the ack had just delivered. The new directory appeared for one paint and vanished. Two halves. `index_delta` now carries the roots table, sealed with the rest and identical to `index_sync`'s — additive, so a 1.0 client sees a field it does not read. And the table no longer refreshes the index after a root change: the ack gives it the new set immediately, and the delta the node pushes when the scan finishes gives it again, along with the files. The test that pins it uses an *eject* as its case, because an eject changes no file at all — the entries freeze — so its delta is empty of additions, deletions and updates. Without the table it says literally nothing, which is how a library disappearing from under a group went unannounced to everyone but whoever pressed the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(node): an upload lands in the folder it was sent toChristophe Besson2026-09-061-13/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | There is no `uploads/` subdirectory any more, and the client names the folder rather than the root. It was the last of v5's quarantine — the per-user layer went on 2026-08-14 for the same reason — and it goes on the same grounds: a folder appearing beside the operator's library because somebody sent a file is the node deciding how their disk is arranged. Somebody dropping a file into the folder they are looking at expects it to be in that folder. **What made the quarantine worth having was never the subdirectory.** It is the filename allowlist, the size cap, the chunk ordering and the no-overwrite rule, and all four are untouched: an existing file is never replaced, the second sender of IMG_1234.jpg gets a free name, and the check still sits at the write. Letting the client choose the destination is safe for one reason and only one: it is resolved through `RootSet.resolve()`, which refuses `..`, absolute segments and anything whose resolved form escapes its root, symlinks included. A member answers "which of this group's folders", never "which path on the operator's disk" — and the test that used to assert the node chose now asserts that, with six shapes of escape. `direct` goes with it. Its only job was to say "no subdirectory for this root", which is now every root, and a config flag that does nothing is worse than none. Chat's attachment folder finally does something: the directory the operator picks in the Chat settings pane is where attachments are written, falling back to the first writable root while they have not chosen one, or if the one they chose has since been made read-only or ejected — a stale choice should not become a refusal at send time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(node): the MNP root path never reloaded, and my first repair made it worseChristophe Besson2026-09-061-1/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit was the wrong fix. `add_root` did leave the running node unchanged, but editing the live `RootSet` in place — which is what I did — is wrong in the other direction. `DirectoryIndexer.retarget` decides what to scan by diffing the names it already holds against the ones it is handed, and `_retarget_indexer` hands it `groups_ctx[gid]["roots"]`: the very object the op had edited. So the new root sat on both sides of the comparison, nothing was scanned, and the directory would have appeared in the table permanently empty. `_reload_config_inner` diffs the same way and would have concluded nothing changed. `remove_root` had the same shape and would have kept serving a removed directory's files. The real defect is that two front doors did different things. `ui/app.py` has always fired the daemon's `reload_fn` after these ops, which re-reads node.toml and builds a *fresh* set; the MNP path retargeted a stale object instead. That asymmetry is exactly what `ops.py` exists to prevent, and it is why the bug survived until an operator added a directory from a browser — the loopback path worked all along. So: the ops leave the live set alone, `_retarget_indexer` asks the daemon to reload, and `update_root` keeps editing in place because flags change no files and the synchronous upload handler reads that object on the next request. The tests now check the files rather than `describe()`, which proves nothing about whether anything was scanned. One of them demonstrates the failure mode instead of describing it, so the rule is checkable and will say so if `retarget` ever changes. Two more cover the seam itself — that the MNP path reloads, and that a context with no daemon still retargets. Diagnosed by reading the running node's journal rather than the source: the first add logged "Reloading config" and a rescan, the two later ones logged neither. I should have looked there before the first attempt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(node): Phase 2 server side — one directory setting for every appChristophe Besson2026-09-061-0/+190
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `video_root` (a string), `audio_root` (a string) and `photo_roots` (a list) said the same thing three ways: three roster accessors, three ops, three MNP messages, three admin-op subjects. They become `set_app_directories(app_key, paths)` and its single-directory wrapper, stored under `<app>_directories` and keyed by the app's registry name — so an application can be added without touching this layer, which is the whole claim of the plugin architecture. The three old names still work. Their MNP messages are handled, and the roster falls back to the old key when the new one is unset, so a node upgraded into this keeps working with no migration step — the plan called for a script, and a script nobody runs on the machine where it matters is worse than a fallback. Two things are new rather than moved: The paths are validated. The setters this replaces accepted anything, so a typo — or a path left behind when a root was removed — was stored happily and then matched no entry, leaving an app showing an empty tab with nothing to distinguish "misconfigured" from "no files yet". Deliberately not `RootSet.resolve()`: that also refuses a currently-unavailable root, and an operator must be able to point an app at a library on a drive they ejected. The legacy scalar is derived, never stored. `video_root` still rides on the handshake ack for MNP 1.0 clients; kept as a second stored value it would drift from the list within one run, which reads as "it works after a restart". Also here: chat's own two settings (a directory, which must be on a read-write root because it is a destination rather than a view, and a link-preview switch gating the unfurl path — checked before the cache, or turning it off would still serve every preview already fetched), the `app_directories`, `chat_directory` and `chat_link_preview` MNP messages, the plural `<app>_directories` on the handshake ack, and `music` as the app's one identifier where storage said `audio` and the registry said `music`. The Music enricher now resolves a boundary per configured directory rather than one for the group: with several, a single boundary is wrong for all but one of them, and for Music that is the difference between reading a folder as an artist and reading it as a release. Suite: 11 failures, all pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(groups): finish Phase 1 — MNP root management, upload targets, eject stateChristophe Besson2026-09-061-13/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Review of the Phase 1 commit found the RO/RW model sound but three paths unfinished, each of which broke the flow the phase exists to deliver. Plus 29 test failures it introduced and no coverage for anything it added. Uploads went to the wrong directory. The node read a `root` field on file_upload that no client ever sent, so every upload landed in the first writable root while the Files toolbar offered its button based on the root being browsed — with two writable roots, uploading from one wrote into the other. Files now names the root it is showing; Chat names one chosen in the shell (an operator-configured directory arrives in Phase 2); the node refuses an unknown name rather than falling back, and refuses read-only and ejected roots by code. Shared directories were unreachable on the web. The table read its roots only from the loopback API, which resolves to "not available" in a browser, so the section rendered for nobody there — while the Uploads controls it replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods beside it were dead. MNP is now the path, loopback the fallback for a local node with no live connection, and adding a root over MNP takes a typed path since no web page can browse a remote disk. Ejecting updated nobody's screen. transport.js resolves an admin ack against the pending request and returns, which is right for every op whose caller knows the value it chose; the root acks carry state only the node can compute, so the operator who clicked Eject was the one client that never saw it happen. And the ejected flag reached roster.db but was never read back, so a restart undid it and the next scan read an empty mount point as an erased library. Also: the member-upload endpoint answered 200 and did nothing (removed); the wizard ignored the first root's RW switch; reload compared roots on name and path, so editing writable in node.toml did nothing; the table had no path column, which is the only thing separating two libraries sharing a basename; apps_enabled normalisation differed between the two sides of a signed subject. Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no coverage at all. test_member_upload_policy.py is replaced by test_root_writable_policy.py — it tested a removed feature — and every property worth keeping from it moved rather than being dropped. Docs: draft-v6 structural decision 9 is annotated as superseded (the operator can no longer have a directory only they may write to — a real capability removed, flagged rather than hidden), the man page documents the root verb and the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong. Suite: 41 failures before, 13 after — all 13 pre-existing on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat: groups refactor Phase 1 — root RO/RW model + shared directories UIChristophe Besson2026-09-061-73/+203
| | | | | | | | | | | | Replace the upload boolean with per-root writable/removable/ejected flags. Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol messages, live RootSet updates so API always reflects current state, CLI root subcommand (add/remove/set/list/eject/plug). Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug in Files and Settings, upload gated on root.writable, ejected-root filtering in all media apps, updated Create Group wizard, 10-locale i18n. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): log the host candidates the WebRTC answer offersChristophe Besson2026-09-041-2/+15
| | | | | | | | | | | "DataChannel closed" from a peer and a clean node log look identical: the answer-ready line reported only the srflx count, not the host addresses. On a NAT'd host or a VM the sole host candidate is an address no other machine can route to, and that is exactly the case you cannot see. The line now reads `... host: 192.168.200.173, 1 srflx`, so "did the node offer anything routable" is answerable from the journal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(node): make ice_interfaces match adapters on Windows (W9)Christophe Besson2026-09-041-17/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ice_interfaces` compared the operator's entry against ifaddr's `adapter.name` only -- the kernel name on Linux (`wlp3s0f0`), but the adapter GUID on Windows (`{846EE342-...}`). A setting written on Linux, or copied into a Windows node's node.toml, matched no adapter at all. The failure was silent and total rather than partial: aioice binds one socket per host address, so an empty list means no sockets, no host candidates, and an SDP offering only a reflexive address. The settings field is free text with no picker, and on Windows the operator sees neither the GUID nor the description -- `ipconfig` shows the connection name -- so an entry now matches the adapter name, the device description, or one of the adapter's own IPv4 addresses, case-insensitively. An address is the one identifier visible on every platform. A filter that matches nothing now falls back to the unfiltered list with a warning. Losing the 5 s timeout saving is a regression; being silently unconnectable is a defect. Also fixes IPv4/IPv6 discrimination in the same loop: the two were told apart by falling through to an `elif` that index-probed `ip.ip[0]` and `ip.ip[2]`, which on an IPv4 str yields characters that compared unequal by luck rather than by design. Now discriminated by isinstance. WINDOWS-PORT.md claimed Transport had "no platform dependency"; it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(node): pin utf-8 (and LF) on every text file the node reads or writesChristophe Besson2026-09-041-2/+2
| | | | | | | | | | | | | | | node.toml, the keystore envelope, the unlock key, the loopback UI token, pairing/invite code files and the denylist were all read and written with the platform default encoding and newline translation. On Windows that is cp1252 + CRLF: a node.toml or keystore holding any non-ASCII byte failed to load, and ops.py's line-based node.toml editor round-tripped CRLF in and LF out. Every read is now `encoding="utf-8"`; every write is `encoding="utf-8", newline="\n"` so the files stay LF whatever the OS. No-op where the locale was already UTF-8. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(node): platform abstraction for Windows portability (W1-W2-W5-W6-W7)Christophe Besson2026-09-033-11/+15
| | | | | | | | | Platform directories, signal handling, chmod guards, ffmpeg discovery, and platform-conditional CLI messages — all testable on Linux. See docs/WINDOWS-PORT.md §5 for the plan these implement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat!: MNP 1.0 — seal index and handshake_ack under the group keyChristophe Besson2026-09-034-24/+139
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
* refactor!: one file_chunk and index_sync encoder for every transportChristophe Besson2026-09-034-156/+118
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `file_chunk` and `index_sync` were each built twice, once per transport, and the two copies did not agree. WebRTC sent binary, unsigned chunks carrying a `file_id`; QUIC sent base64 fields, two BLAKE3 hashes, a per-chunk Ed25519 signature and no `file_id`. `index_sync` was plain entries on one transport and a `GroupIndex.serialize()` envelope on the other. One message type, two shapes, one consumer each, and nothing that failed when they drifted — finding C6 one size down, in the two places the handshake unification did not reach. Phase 9.15 moved WebRTC to the binary format and dropped the per-chunk signature; the QUIC encoder was never brought along. It is dropped here rather than reintroduced: the AES-GCM tag authenticates the ciphertext under a GEK-derived key, and since C3 the node authenticates itself once in the handshake instead of once per megabyte. `meshbay_common.protocol` now owns the chunk codec (`chunk_ciphertext`, `file_chunk_wire`, `file_chunk_plaintext`) and `meshbay_node/transport/wire.py` the index builder, which also absorbs the delta the daemon used to hand-build. `test_transport_wire_parity.py` fails if either server grows its own copy back. `ChunkRequest`/`ChunkResponse` are deleted. `ChunkResponse` described the QUIC half while reading like the contract for both, which is what made the fork hard to see at all. BREAKING CHANGE: MNP 0.15 changes the encoding of `file_chunk` and `index_sync` on the QUIC transport. The WebRTC shapes are byte for byte unchanged and no QUIC client ships, which is why this is a MINOR bump; a deployed QUIC peer would have made it MAJOR. Also fixes a test fixture that put a `Path` where the daemon puts a `RootSet`. Nothing caught it: the old QUIC index handler never touched `roots`, and `entry_abs_path` fell through `Path.resolve(strict=...)`, reading the virtual path as a truthy flag and returning the right file by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* fix(hub): steady the show detail modal, and give a series its directorChristophe Besson2026-09-021-0/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a different season of the same show moved everything under the synopsis, which is where the season control and the episode list are, so the thing just clicked was no longer under the pointer. - The synopsis is exactly three lines for a multi-season show, with a "read more" link floated into the third line box (-webkit-line-clamp only ever puts its ellipsis at the end of the last line and leaves no room after it). Clamped from above and pinned from below to the same number: a constant, not a range — a season summary runs two lines and the next one twelve, and a band still reads as a jump. Whether three lines is all of it depends on the modal's width, so it is measured in the browser and re-measured on a resize. - The cast is clamped to two lines. - SeasonMenu replaces SeasonTabs: the tab row scrolled sideways once a show had more seasons than fit, which is close to unusable on a phone. One trigger reading "Season 5 · 1997" and a menu of every season with its episode count, one row high whatever the season count. - media_meta_resp.director was filled from the credits crew's job == "Director", a movie shape. TMDB's aggregate tv_credits crew is routinely empty and never carries that job, so every show answered null and the modal dropped the line. It now comes from created_by on the show details. Cached show metadata keeps its null until TMDB_META_TTL_SECS expires or an operator re-matches. The facts line is joined rather than concatenated (a title with no rating used to open with " · ") and carries the show's own year next to the director; the selected season's air year moved onto the picker. test_video_detail_measured.py asserts rectangles through layout_probe.py, not declarations: the picker's offset inside its own modal body is the same pixel either way, the synopsis and cast heights, where the read-more link lands, and the open menu at 320 px. Each measured block sits in a whole-pixel-height container, or two identical layouts an eighth of a pixel apart round to tops one pixel apart. test_tmdb_show_director.py covers the credit. docs/mediacenter.md §10.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(node): bound and tighten the chat link-preview SSRF surfaceChristophe Besson2026-09-011-0/+40
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The link-preview fetch is an outbound request to an address a member chose. safe_url() already blocked non-public addresses and re-checked each redirect hop; this adds the parts that were missing: - Rate limit. `_do_link_preview_request` was reachable by any member with no ceiling, so a member — or a hub minting tokens for many accounts — could drive unbounded outbound HTTP from the operator's machine (amplification / DoS / on-demand IP disclosure to arbitrary hosts). Now bounded per connection (15) and node-wide (60) over a 60 s window; only a real fetch counts, a cache hit is free, and over the ceiling the reply is a plain `ok: false` (bare link), not cached. - Port allowlist. safe_url() passed `parts.port` straight through, so a member could aim the node at `http://<public-host>:<any-port>`. Restricted to {80, 443, 8080, 8443} — every real OpenGraph page, none of SSH / mail / DB / cache / search / admin ports. - DNS rebinding. The connection's actual peer address is now re-checked against the public-address rule (`_reject_if_rebound`), so a name that resolves clean and then to something internal does not get its body read. Best-effort (no `network_stream` extension, no check); a full literal-pin is noted as remaining hardening. - Decompression bomb. `_downscale` now refuses an image whose header dimensions exceed ~40 MP before convert()/thumbnail() decode it. Third security review, finding M3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(node): bring the QUIC chat and stream handlers to WebRTC parityChristophe Besson2026-09-011-49/+95
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The unified handshake reached QUIC in Phase 11.5, but the chat and stream handlers did not get the authorization rules the WebRTC path gained at the same time: - _do_chat_message_sync took `sender_id` from the wire, so an authenticated peer could post as anyone (NS6 / M2a). It is now the authenticated session's id, always. - chat used a connection-global peer registry and read chat_store from the top-level context, so on a multi-group node a message from one group fanned out to peers of the others (M2b / H1). Both are now resolved per group via _peer_registry() / _group_ctx(), mirroring the WebRTC path. The QUIC peer set is kept separate from the WebRTC one in the same group context — the two session types have different _send signatures and no cross-transport fan-out is wired. - _do_stream_segment_sync ran `subprocess.run(timeout=30)` on the event loop with no concurrency cap, so one request stalled the whole node and any member could fork-bomb it with ffmpeg (M2c). Extraction now runs in a thread behind a small semaphore, spawned as a tracked task (cancelled on connection_lost). Also corrects the stale docstring claiming C6 is still open here — the GEK proof has been enforced on this transport since 11.5. No behaviour change for shipping clients: none speak QUIC, and the listener is off by default (previous commit). Third security review, finding M2. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(node): CLI member invite now registers hub membership and enforces codeChristophe Besson2026-09-011-15/+16
| | | | | | | | | | | | | | | | | | Two bugs fixed: 1. `meshbay-node member invite <user>` created a local roster invite but never told the hub to add the user to group_members, so the group was invisible in the SPA. The node now calls POST /v1/groups/{id}/members/{username} after creating the invite, and the hub endpoint accepts node-scoped tokens (the admin_id check is the real authorization guard). 2. The WebRTC handshake let a previously-pinned user reconnect without a code even when a new invite was pending (e.g. after leave + re-invite). Now any pending invite forces code entry, regardless of existing member/pin status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): make WebRTC STUN fallback actually use every configured serverChristophe Besson2026-09-012-2/+161
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | aiortc's connection_kwargs() keeps only the first STUN URI from RTCConfiguration.iceServers ("only a single STUN server is supported"), and aioice.ice.Connection has a single stun_server field. So the node's four default STUN servers -- and anything added on the Node page or with `meshbay-node stun add` -- collapsed to stun:stun.l.google.com:19302. When that one server was slow or unreachable from the node, ICE gathering (get_component_candidates, timeout=5) burned its full 5 s with no server-reflexive candidate, adding seconds to every browser connection. The multi-server fallback of draft-v6 s2.12 was configuration only. transport/stun_multi patches aioice.ice.server_reflexive_candidate (same monkey-patch technique ice_filter.py uses on get_host_addresses) so a single ICE gather races the STUN binding request against every configured server on the one bound socket and takes the first answer. One reachable server anywhere in the list now yields a reflexive candidate in one RTT. - daemon: install_stun_multi() alongside install_ice_filter() - ops.set_node_settings: push the list to stun_multi.set_servers() so the CLI / Node-page hot-swap takes effect without a restart - webrtc_server.handle_offer: log ICE gather time and srflx count - test_stun_multi.py: fan-out, first-answer-wins, all-fail, empty-list fallback, DNS failure Verified end to end with a real RTCPeerConnection: with a black-hole STUN server first in the list, gathering still completes in ~0.07 s with full srflx candidates (previously a 5 s stall). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSsQhfxEAhwi4nqc4hASmq
* feat: passphrase change and account recovery (auth-confirm)Christophe Besson2026-09-011-7/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
* feat: configurable STUN server fallbacks for WebRTC ICEChristophe Besson2026-08-301-1/+2
| | | | | | | | | | The WebRTC transport relied on a single Google STUN server — if it was unreachable, ICE gathering waited the full 4s timeout. Now four public servers are used by default (Google ×2, Cloudflare, Mozilla), configurable via node.toml, the Node page UI, and the CLI (meshbay-node stun list|add| remove|reset). Changes are hot-swapped on the live transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): editable node settings in the Node page (D5)Christophe Besson2026-08-291-0/+20
| | | | | | | | | | | | | Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes, max_concurrent_streams and transcode_incompatible_video in the Node management panel. Changes are applied immediately via roster.db and written back to node.toml so they survive a DB wipe. On startup, roster overrides take precedence over node.toml defaults. Draft v6 §2.11 documents the design; MNP gains node_settings_set / node_settings_set_ack for the browser path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: on restart, serve a video's cached TMDB match instead of re-searchingChristophe Besson2026-08-291-11/+33
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Root of the 2026-08-29 demo35 storms. `_do_media_meta_request` could not tell "this is a movie" from "this video isn't enriched yet" — both have season/episode None — so during a slow initial scan with a browser on the Videos tab, every show episode requested was run through the *movie* search path with its raw filename as the query (`search/movie?query=Show S01E01 1080p WEB DL ...`), hundreds per second, until TMDB rate-limited and posters stopped loading. Worse, an un-enriched show episode's own valid cached "tv" match was treated as stale (its provisional kind was "movie"), so a file already resolved got re-queried anyway. - While a video is un-enriched (no display_title — enrich.py always sets one), never run a TMDB *search*. Serve the cached match if the content hash has one, honouring the cached kind ("tv"/"movie") rather than the provisional split; otherwise answer confidence 0. - Once enriched, the strict `cached_media_type == media_type` check is unchanged: an enrichment fix that reclassifies a folder movie->tv still drops the stale match and re-resolves. - video-app.js: `useMediaMeta` gains an `enrichSig` dependency (`entry.display_title`) so the client refetches once the enriched fields arrive on an index delta — the fileId is a content hash and never changes, so nothing else would retrigger it. Not caused by the V8-V13 work; it raised the per-file call count so the pre-existing race became a visible storm. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat: V13 — per-card "re-match this one file" (MNP 0.13)Christophe Besson2026-08-291-0/+49
| | | | | | | | | | | | | | | | | | | | | | | | A one-click alternative to the full "Fix match" search-and-pick flow, and reachable without SSH (`meshbay-node video rematch` clears a whole group). - MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node logs "unknown type", the button just does nothing). OP_TMDB_REMATCH, signed like tmdb_override (media_cache is shared node-wide). - media_cache.drop_tmdb_match(file_id): forgets the match AND the override marker — deliberately stronger than clear_file_tmdb, since the operator is explicitly asking for a fresh resolution. - webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch + admin-response routing, broadcasts tmdb_rematch_ack. - transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack. - video-app.js: a "Re-match" button beside "Fix match" in the detail modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one key in all ten locales. docs/mediacenter.md §10.1: V8–V13 marked done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(node): V8–V11 — show-branch ladder, year-aware _best_match, wider ↵Christophe Besson2026-08-291-54/+62
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | sequel_variants V8: the TV/show branch of _tmdb_search used the old "first candidate over 0.6 wins" shape. It now shares one _tmdb_ladder helper with the movie branch — score every candidate query, keep the best, fast-path a confident primary hit. A year lifted off the show's folder name (title_parse.year_in, e.g. "Some.Show.2022.S01") rescues a sub-0.6 hit that lands on the exact year. title_parse.clean_query de-dots a folder-derived title without naive_title's extension-stripping trap. V9: _best_match gains an optional `year`. 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 this is a hard corroboration, not the fuzzy re-rank §3.3 warns against. A confident top hit is never overridden. search_movie/search_tv forward the year. V10: sequel_variants widened — trailing Roman→digit as well as digit→Roman, spelled-out indices (one..twelve / un..douze / ordinals), and a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year. V11: when the primary hit is already decent (>= 0.6) and there is nothing more specific to try (no alternative_title, no sequel variant — only a punctuation restatement left), the ladder returns without the extra requests. The clean-title common case is back to one call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* Merge branch 'fix/videos-tmdb-matching'Christophe Besson2026-08-291-19/+84
|\
| * fix(node): correct TMDB movie matching, per-file overrides, rematchChristophe Besson2026-08-291-19/+84
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* | perf(node): filter ICE interfaces to eliminate 5s STUN timeout on ↵Christophe Besson2026-08-291-0/+82
|/ | | | | | | | | | | | | | | VPN/virtual adapters aioice sends STUN binding requests from every IPv4 interface and waits up to 5 seconds for all to complete. On a machine with Tailscale (wt0), the STUN request never gets a response, adding a fixed 5-second penalty to every WebRTC connection — measured at 6 s total (vs 1-2 s without it). Auto-exclude virtual/VPN adapters (tailscale, virbr, docker, veth, podman, cni) and CGNAT-range IPs (100.64.0.0/10). Operator can override with ice_interfaces in node.toml [node] section for explicit control. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contactChristophe Besson2026-08-291-61/+0
| | | | | | | | | | | | | | | | | | | | | | | | Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): a pinned device can be invited to a further invite-only groupChristophe Besson2026-08-281-4/+37
| | | | | | | | | | | | | | | | | | | | | | | | | | | Regression from device linking (Stage C, 2026-08-18). Once a device is pinned on a node — as a member of one group, or an operator pairing — the `known` fast-path in `_do_join_request` dropped straight into `_join_ok`. For any *other* invite-only group it had no roster row for, that answered `not_authorized_for_group` and stopped there: the client never got `code_required`, so the pairing-code form never appeared and a legitimately invited member could not join. The `known` branch now, when there is no membership for the group being opened: - with a valid code → consumes the invite and admits (as the unknown- device path already does); - with no code but an invite waiting for this user here → `code_required`, so the client prompts; - with no code and nothing inviting them → `not_authorized_for_group`, unchanged, so the H3 guarantee (a hub-invented pin gets no key) holds. Also fixed: the group's own roster row is now consulted first, so an existing member opening their group is never mistaken for a stranger. Tests in test_roster_pairing.py cover all three branches plus the H3 guard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(chat): link previews for pasted URLsChristophe Besson2026-08-281-1/+78
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Paste an http(s) link in a group's chat and it unfurls into an OpenGraph card — title, description, site name, and image — the way WhatsApp/Signal/ Slack do it. The fetch is the node's, never the browser's or the hub's. The browser cannot: a strict img-src/connect-src and CORS block it, and a direct fetch would leak every reader's IP to the linked host on each render. The hub must not touch group content (draft-v6 §2.5). The node already fetches third-party metadata for the Videos and Music apps, over the same authorised path. Flow mirrors media_meta_req: the client sends `link_preview_req {url}`, the node replies `link_preview_resp` with the card fields (or `ok: false`), and any OG image is stored under its blake3 in the existing media_cache thumb store — the client then fetches it via the normal file_req path, exactly like a poster. Nothing durable is added: the card text lives in a bounded in-memory TTL cache on the node (draft-v6 §2.7 — enrichment on demand, the asking device caches), and MNP goes 0.11 → 0.12 (additive: an older node logs "unknown type" and the client shows the bare link). Because the URL is chosen by a *member* and triggers an outbound request from the operator's machine, `linkpreview.safe_url` is an SSRF gate: http(s) only, no credentials, and every resolved address must be globally routable — no loopback, private, link-local, multicast or reserved range, cloud-metadata included. Redirects are followed by hand so each hop is re-checked. Residual, documented in the module: DNS rebinding between the check and connect, closed properly by pinning the checked IP — a follow-up. Also fixes a long-standing chat annoyance the preview cards made worse: opening the Chat tab landed a screen or two above the newest message because the scroll-to-bottom ran before attachment thumbnails and (now) preview cards had loaded and grown the content. A ResizeObserver keeps the view pinned to the bottom through late content growth, and does nothing once the reader scrolls up. Tests: test_linkpreview.py (the SSRF gate and the OpenGraph parse, incl. redirect re-validation and image downscaling) and test_link_preview_request.py (reply shape, the media_cache image round-trip, the result cache). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(video): a TMDB override never stored the metadata its chosen id namesChristophe Besson2026-08-261-0/+18
| | | | | | | | | | | | | | | | | | | | | | | Found live: "fix match" appeared to work for two shows but not for a movie whose own automatic search kept landing on the same wrong result. The override only ever recorded the file->tmdb_id mapping — never the metadata that id actually names. _do_media_meta_request's cache check agrees the mapping is fresh (same media_type) but finds nothing under that *new* id in tmdb_meta, since nothing had ever fetched it, and falls through to a brand-new search using the file's own title — reproducing the exact match the override was meant to replace. This stayed invisible for the two shows only because their own title happened to be enough for that fallback search to land on the right answer anyway, entirely independent of whatever the override recorded — never because the override was actually being honored. It surfaced on a movie whose own title search kept landing on the same wrong match regardless. Fetches and stores the real metadata for the chosen tmdb_id up front (via the existing _tmdb_build_meta, which needs only the id — no search result object required), so a later lookup finds the override itself instead of falling through to a search blind to it.
* fix(video): TMDB match cache never invalidated on movie<->show reclassificationChristophe Besson2026-08-261-2/+14
| | | | | | | | | | | | | | | | The real reason a node restart alone didn't fix already-indexed entries after the previous commit's enrichment change: _do_media_meta_request checked media_cache's file->tmdb mapping (keyed by content hash) and trusted it unconditionally, before ever comparing it against the file's *current* movie/show classification. A file whose season/episode changed on a later scan — exactly what the Specials-folder fix does, for every file it reclassifies from "movie" to "tv" — kept answering with its stale, wrong-kind-of-match forever, since nothing about a reclassification touches this cache or its key. Now falls through to a fresh search whenever the cached media_type disagrees with what the entry resolves to right now, rather than trusting a mapping that predates the file's current classification.
* debug(transport): opt-in WebRTC health tracing for mobile-lock investigationChristophe Besson2026-08-261-0/+39
| | | | | | | | | | | | | | | Client (transport.js): a localStorage ring buffer of connection/ICE/ DataChannel state transitions, visibility changes, request timeouts, and periodic health pings — enabled once via ?trace=1 (persists), read back at any time via #mb-debug without devtools. Off by default, zero behavior change unless enabled. Node (webrtc_server.py): MESHBAY_WEBRTC_TRACE=1 gates ICE-state-change logging and a per-session heartbeat (message count, seconds since last message, ICE/connection state) every 30s. Debugging aid for the "stuck after several minutes of mobile screen lock" report — not a fix. Stays on this branch until confirmed useful/resolved.
* fix(node,hub): key music/media metadata lookups by file_id, not pathChristophe Besson2026-08-261-38/+58
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | IndexEntry.path is the *folder* a file is in (indexer.py's _virtual_dir docstring: "the directory a file appears in"), not the file itself. GroupIndex.get_entry_by_path() treated it as if it named one file, and every one of its four callers did too: _do_music_meta_request, _do_media_meta_request, _do_tmdb_override, and _admin_exec_tmdb_override. Any two files sharing a folder — an album is one folder with many tracks, a season is one folder with many episodes — collided: a lookup by path silently returned whichever entry the index happened to iterate to first, regardless of which file the client actually asked about. Found live (2026-08-25): three unrelated albums ("High Tone - Various", two "Le Peuple de l'Herbe" albums) all showed the same MusicBrainz cover, because all their representative tracks happened to sit in one "high_tone" folder alongside a track that legitimately matched that cover. A force-reload didn't help — the bug is server-side, not a stale client state. Fixed by keying these four request/response pairs by `file_id` (the entry's own content hash — already unique, already how every other lookup in the system identifies a file) instead of `path`, both in the wire messages (music_meta_req/resp, media_meta_req/resp, tmdb_override) and in music-app.js/video-app.js's own hooks. GroupIndex.get_entry_by_path is now unused and removed — GroupIndex.get_entry(file_id) already did the right thing. No test previously exercised either handler with two entries sharing a folder — the only existing coverage (test_tmdb_override_policy.py) gave each entry its own folder, so the bug never had a chance to show up. Added that scenario there and in two new test files, all confirmed failing against the pre-fix code before being confirmed green against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* feat: add Photos group appChristophe Besson2026-08-251-1/+75
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new group application (docs/apps.md's plug-in mechanism), following the plan in docs/photos.md. Unlike Videos/Music: several photo roots per group instead of one (photo_roots is a set, one signed op replaces it whole), a single album-grid view with no third-party matching step, and per-photo info read from the file's own EXIF at index time — no metadata service, no credential, no outbound network call at all. Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera` on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`. Node: roster.py stores photo_roots as a group_settings entry (JSON list, same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the whole set in one op, same pattern as apps_enabled; a new PhotoEnricher (indexer/enrich_photo.py) runs Pillow in its own small bounded pool, separate from the video/audio pools, producing a resized thumbnail plus the two EXIF fields — never GPS, checked by a grep-based regression test. Client: photos-app.js — one album card per directory containing images, a per-album photo grid, and a lightbox with next/previous (keyboard and buttons), zoom in/out/fit/100% starting from the actual on-screen fit percentage, and a "zip this album" button reusing files-app.js's own zip mechanism (lifted into file-utils.js's downloadDirectory so both call the same implementation). group-settings.js gets an add/remove multi-root picker, distinct from Videos/Music's single-value one. Bugs found and fixed before this ever shipped, worth keeping the story of: - enrich_photo.py read width/height from the raw image *before* applying EXIF orientation correction, and read DateTimeOriginal off the plain 0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict round-trips through Pillow either way, which is exactly what would have hidden both bugs; the regression test builds EXIF with piexif instead, matching what real hardware produces. - photos-app.js's album grouping stripped a trailing path segment from entry.path under the assumption it still carried a filename — it doesn't (files-app.js's own convention: e.path is already the containing directory), so every album collapsed one level into its parent. Found live against a real multi-folder library. - transport.js's ADMIN_OP_TYPES allowlist (already the fix for an identical bug on video_root/apps_enabled, see 4783d81) was missing photo_roots: its admin_challenge matched no pending request and was silently dropped, so saving a photo root just timed out after 30s with no error. - daemon.py pruned a thumbnail when its file left the index (root removed or reconfigured) but never forgot the content hash was "already attempted" — the same bytes reappearing under a renamed/relocated root (an operator's real workflow) were then permanently skipped, forever, with nothing to indicate why. Discarding the attempt alongside the cache entry on prune is what makes pruning actually reversible. - packages/meshbay-client's app:// protocol handler served every file with no Cache-Control header, so Chromium was free to serve a stale cached copy indefinitely — none of several `npm run sync-ui` + reload cycles during development actually picked up the new code until the renderer's disk cache was cleared by hand. Now sends Cache-Control: no-store. - the lightbox's zoomed image used flex centering (align-items/ justify-content: center) combined with overflow: auto — a well-known trap where the browser centers overflowing content by shifting it, and the leading half of that overflow (here, the top of a zoomed photo) sits outside what the scrollport can actually reach. Reported live as "unusable". Fixed by switching to top/left alignment once zoomed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
* fix(hub): keep an unmatched admin_challenge visible, add trace loggingChristophe Besson2026-08-241-0/+2
| | | | | | | | | | | | | | | | | | A real report showed audio_root timing out with *nothing* logged in between the connection handshake and the timeout — no admin_challenge, no error, nothing. The previous fix made an unmatched admin_challenge return silently (correctly, to stop it stealing an unrelated pending request — see the earlier commit), but that silence is indistinguishable from "the request never reached the node at all", which is exactly the ambiguity blocking this investigation. An unmatched admin_challenge is now logged (op, op_id, and the full set of currently-pending keys) instead of dropped quietly, and setAudioRoot/_authorizeAdminOp trace both hops of the round trip explicitly. Node-side, _do_audio_root gets a debug log at entry — cheap, and the only way to know from server logs alone whether the request was ever received if the client-side trail comes up empty. Diagnostic only: no routing behavior changed from the previous fix, verified against the same reproduction script.
* feat(node): add audio_root, gate Music enrichment on it like video_rootChristophe Besson2026-08-241-0/+50
| | | | | | | | | | | | | | | | | | | | | | | musicbay.md's original call — Music needs no root, tag reads are cheap so just cover the whole shared tree — didn't hold up against a real messy library: everything under every shared folder got mixed together with no way to scope Music down to an actual music collection. This adds an audio_root setting, symmetric to video_root in every respect: signed operator op (audio_root/audio_root_ack, MNP bumped to 0.10), validated against a real directory in the group's own roots before a signature is even asked for, gates tag/cover enrichment exactly like video_root gates ffprobe/TMDB (nothing runs until it's set, only files under it once it is), and a set/change fires a one-off sweep of whatever the folder already contains. The old trigger — sweep everything the instant "music" joins enabled_apps — is gone along with the root-less design it belonged to; setting audio_root is now the trigger, mirroring set_video_root's enrich_video_root_fn exactly. Test coverage mirrors the video_root suite: policy (refuse before a signature round trip, accept/store correctly) and the enrichment gate itself (nothing without a root, only files under it, sweep on set).
* feat(music): transcode WMA/Musepack to AAC so they actually playChristophe Besson2026-08-241-7/+129
| | | | | | | | | | | | | | | | | | | | | Tagging and covers for these two formats landed already, but neither one decodes in any mainstream browser's <audio> element at all — a real library scan turned up 273 such files that would show up correctly in the Music app and then simply fail on click. This closes that gap: the node transcodes to AAC/M4A on request (a one-shot whole-file conversion, not live-piped like video's fMP4 segments — an audio file is small enough that streaming it buys nothing), caches the result under its own content hash the same way a TMDB poster or a MusicBrainz cover is cached, and serves it back through the ordinary file_req/chunk path. That path used to assume anything in the media cache was thumbnail-sized (single chunk, always); generalized it to slice a cached blob the same way a real file on disk gets sliced, since a transcoded track can be several MB. New MNP pair (`audio_transcode_req`/`_resp`, version bump to 0.9), shares its concurrency cap with video's transcode pool rather than getting its own — both are real ffmpeg processes on the same node. Every other audio format is untouched: this only fires for .wma/.mpc, the two extensions that need it.
* feat(node): Music app node-side — indexing, MusicBrainz enrichment, protocolChristophe Besson2026-08-241-6/+212
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Implements the node half of docs/musicbay.md against MNP 0.8: - IndexEntry gains artist/album/track_no (reuses duration/thumb_hash/ display_title, already generic). New musicbrainz_config/_enabled and music_meta_req/_resp message pairs, mirroring the TMDB shape. - title_parse.parse_track_filename: track-number-prefix + title parsing, fallback-only (embedded tags are the primary source, unlike Videos). - indexer.enrich_audio.AudioEnricher: mutagen-based tag/embedded-cover extraction through its own bounded pool (asyncio.to_thread, no subprocess — no ffmpeg-shaped deadlock risk). Gated on "music" in a group's enabled_apps rather than a video_root-style scoped folder. - musicbrainz.py: MusicBrainzClient — no API key (unlike TMDB), just a self-imposed ~1 req/s pace and a configurable, non-default User-Agent contact string; inert (no calls at all) when no contact is configured, never sends an unidentified client. - media_cache.py: file_mbid/mbid_meta tables alongside the existing TMDB ones, cover art reusing the thumbs table via a synthetic musicbrainz:{mbid} id, pruned on file deletion. - roster.py/ops.py/webrtc_server.py: musicbrainz_contact (node-wide) and musicbrainz_enabled (per-group, from the start) as signed operator settings, ALLOWED_APPS gains "music", _do_music_meta_request resolves and caches a release-level MusicBrainz match per (artist, album). - daemon.py: AudioEnricher/MusicBrainzClient wired alongside the video ones; a group's existing library is swept when "music" is newly enabled (no video_root equivalent — see musicbay.md §2.1). 41 new tests (musicbrainz.py against a mocked transport, admin-op policy for both new settings, media_cache round-trip/pruning, enrich_audio end-to-end against real ffmpeg-generated MP3s). Full suite (common + node + hub): 1116 passed, no regressions. Client-side (music-app.js, persistent player bar) not started yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(node,hub): HEVC transcode fallback, live-add progress, per-group TMDB toggleChristophe Besson2026-08-241-34/+121
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three bugs found live testing the Videos app against a real HEVC/EAC3 show, plus a design change requested afterward: - Streaming always did "-c:v copy", which faithfully reports a source's real hev1 codec string but is unplayable in a browser with no HEVC decoder (most Chrome/Linux builds). The node now transcodes to H264 whenever the probed codec is browser-incompatible (media_probe.py's new BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video` node.toml opt-out for operators who know their viewers already decode it. - Dropping a whole season into an already-watched folder gave no scanning indicator and no progress bar: IndexProgress was only ever updated by the two bulk scan paths, never by the real-time per-file watchdog path (_schedule_update/_debounce/_update_entry). That path now accounts a "burst" the same way, without double-counting a file rewritten mid-debounce. - A stray literal "0" rendered in the video detail modal when there was no TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm, not nothing) — `confident` is now a real boolean. - Whether TMDB is used at all moves from a node-wide setting to per-group (OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT): an operator running a real media-library group alongside test/demo groups on one node wants outbound TMDB traffic for the one that needs it, not all of them. The custom API token and query language stay node-wide, one shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning). MNP_VERSION 0.6 -> 0.7, additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
* feat(node,hub): season-specific overviews, manual TMDB match correction, and ↵Christophe Besson2026-08-241-0/+163
| | | | | | | | | | | | | | | | | | | | | | | | | wizard polish Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY