aboutsummaryrefslogtreecommitdiffstats
path: root/docs
Commit message (Collapse)AuthorAgeFilesLines
* docs(mnp): the protocol reference, brought to the cleanup pushChristophe Besson2026-09-102-0/+2713
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | MESHBAY_NODE_PROTOCOL.md and playlists.md move into the repository, where a reader can follow them. The protocol reference was written against the tree before the cleanup landed and described three things that no longer exist. Corrected here rather than left to be discovered: * member_upload is gone from the handshake ack and from the message catalogue. Whether a member may write is a property of each root, and a summary field beside the authoritative one is a second source for one question — whichever the reader consults first decides it. * video_root, audio_root and photo_roots are gone from the ack, the signed-op table and the message reference. app_directories is not "the general form" of three narrower ops any more; it is the only one. What survives is their storage key on the node, because that is a key on an operator's disk rather than on the wire, and a node upgraded into this has to find its own configuration. * The sender-key implementation the chat section pointed at has been deleted, along with the ratchet. The argument for deriving a key per device stands on its own now instead of pointing at a module to compare against. playlists.md is added as written — a design for a feature that is not built, and the first one to need per-account state spanning several groups on several nodes. Its decided shape is summarised in MESHBAY_DESIGN.md section 9.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* docs: MESHBAY_DESIGN.md — one reference for the designChristophe Besson2026-09-102-0/+3787
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Thirty documents under docs/ described this system between them: two architecture drafts, three security reviews, eleven design notes, a roadmap and a decisions file. Reading any one of them meant following cross-references into four others, and several were flatly wrong — the keystore KDF, the protocol version, and four features whose headers still said "not implemented" months after they shipped. This is the synthesis. It states design rather than history: a section says why the node wraps the group key itself, not which finding made it necessary. Development history, spikes and reversed directions are gone. The security findings survive as section 13, where each label names the invariant it stands for today rather than the defect it was reported as. Two things it is careful about, because hundreds of code comments depend on them. Every short label — C1, H3, NS6, T3, C5b, W2, E9, F1 — is defined in section 13, including the three colliding namespaces (each review numbered its findings from C1, and the code means the second review's). And section 16 maps every "<doc> section n" reference the code makes onto its replacement, so no comment has to be edited to stay resolvable. transfers-v1.md comes in from outside the tree with it. The lease design is section 5.5; what a synthesis cannot carry is that document's failure-mode analysis — every way a slot can be lost, every way a client can be left waiting — and what a live pass found after the work was called done. Every claim was checked against the code rather than the drafts. Suites green: 2256 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* feat(hub): pin the app controls while a library scrollsChristophe Besson2026-09-101-1/+16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Files, Videos, Music and Photos are read by scrolling, and everything that steers that reading left with the first screenful. Three bands now pin under the navigation bar, in a group and on the Search page alike: the tab bar (the search field, on Search), the application's own toolbar, and the file table's column heads. The group's name and description still scroll — they say nothing a reader needs while walking a directory, and the height they would cost is height the list does not get. A band's offset is the heights of the bands above it, and those are not constants: the toolbar wraps to three rows on a phone, grows a field while a folder is being named, and loses its filter on Search. So each band measures itself and publishes `--chrome-h` / `--toolbar-h` (static/sticky.js) and the stylesheet does the arithmetic in calc(), rather than a number written down twice — the fault CLAUDE.md already records against this layout twice over. A band publishes height *plus its own bottom margin*, and paints that margin as a ring of page colour, so the pinned layout is pixel-identical to the flow layout and nothing shifts at the moment a band pins. Three overflow faults came out of it, all of the same class and all of them what "the header does not stay" actually meant on Android — a document wider than the screen leaves everything pinned attached to a viewport the reader can no longer see, the navigation bar included: - a directory's name cell was a bare <td>, so an unbreakable folder name (`Rage_Against_The_Machine_Discography_1992-2000_FLAC`) set the column's minimum: a 527px table in a 390px window - Search's group column did the same at 442px with an underscored group name. It also goes entirely below 768px, where there is no room for it and the breadcrumb already names the group - the shared-directories table has four columns of controls with a combined minimum near 440px, none of it compressible. On a phone the row stops being a row: the name and its eject/remove pair on one line, the two switches — each carrying the column head's own string as a label — on the next - and, found by measuring at 360px, the tab bar itself was 19px too wide `.file-table` moves to separated borders: a collapsed border belongs to the table rather than to the cell, so the column heads lost their rule the moment they pinned. Measured, not read. tests/harness/sticky_header_probe.py drives the shipped GroupPage and SearchPage against a stub node, walks to each application, scrolls to the end and reports every rectangle — 11 views x 4 widths x 2 engines. Its fixture says what real data says: the first version used `note-007.txt` and `un groupe`, which fit any screen, and found none of the above. A fixture narrower than real data tests the fixture. Also: `test_desktop_shell` no longer looks for the CSP after the first `-->`, which made it fail on correct markup as soon as a comment was added above it, and `search-page.js` joins test_hook_ordering's file list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN
* fix(node): a video the browser cannot decode is re-encoded, not refusedChristophe Besson2026-09-091-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Streaming an Xvid/MP3 .avi answered "Unsupported video codec" — a refusal, on a file ffmpeg re-encodes at about six times playback speed on the machine that reported it. Nothing about the source was wrong. The node simply never reached its own re-encode path. `probe_video` maps a source codec to an MSE codec string and knows four: h264, hevc, vp9, av1. Everything else returns None, because there is no MediaSource decoder in any mainstream browser to give a string to — MPEG-4 Part 2 (Xvid, DivX), MPEG-2, VC-1, WMV, Theora. `_stream_video_inner` read that None as a verdict on the file and refused, while the re-encode sitting twenty lines below it was gated on `raw_video_codec in BROWSER_INCOMPATIBLE_VIDEO_CODECS` — a set containing "hevc" and nothing else. So the whole ffmpeg fallback existed, worked, and was unreachable for every codec that most needed it. The setting that governs the fallback has documented the intended behaviour since it was introduced: draft-v6 §2.11 says `transcode_incompatible_video` covers "HEVC *and other browser-incompatible video codecs*". Only HEVC was ever wired up. Two questions were being answered by one value, and they are separated now. "Is there a video stream at all" is the only thing this path genuinely cannot serve, and the only refusal left. "Can it be copied" needs both an MSE string to put in `stream_init` and a codec browsers decode; a source failing either is re-encoded. The operator's opt-out keeps meaning what it says, and it no longer means the same thing for every source, because it cannot: HEVC has a codec string, so `transcode_incompatible_video = false` falls back to a copy and the viewer's own decoder decides (unchanged). MPEG-4 Part 2 has none, so there is nothing to fall back to — a `stream_init` with no codec string is one the client refuses before the first byte — and the stream is refused naming the setting. "Unsupported video codec" is what sent this report to the file, and the file was fine. Verified against the reported file end to end: ffprobe reports mpeg4/mp3 720x404, the decision comes out `can_copy=False`, and the pipeline's exact argv produces H264 High level 4.1 plus stereo AAC-LC — matching the `avc1.640029,mp4a.40.2` that `stream_init` advertises and that the client puts through MediaSource.isTypeSupported byte for byte. test_stream_hevc_transcode.py becomes test_stream_video_transcode.py: it was always about the policy rather than about one codec, and it now carries both halves of it, with a synthetic Xvid/MP3 .avi built the same way as the HEVC clip. Its module-level skip on libx265 went with it — an ffmpeg without x265 still encodes MPEG-4 Part 2, so that marker was skipping the reported defect entirely on any box without it; it now gates the HEVC cases alone. Three cases added, checked against the unfixed source. Hub and node suites 2269 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
* fix(spa): a reconnect must give the Chat composer backChristophe Besson2026-09-091-1/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The Chat tab froze about every other day — the textbox stopped taking clicks — and it never recovered on its own: no timeout ends this one, only leaving the group or restarting the client. A console dump of a session it happened in ruled out everything it could and named nothing. What that dump established was almost entirely negative, and that was the useful part. No `Response timeout`, no `unsolicited`/`unrouted`/`with nothing waiting` — so the 2026-08-30 routing defect, which produces this exact symptom for thirty seconds, had not recurred. No `PC state: disconnected|failed`, no second ICE cycle, no `Reconnected after N attempt(s)` — so the connection was alive and untouched. The freeze was in the page, and no path that logs anything had run. The composer is `disabled=${sending || cannotSend}`, and `cannotSend` was `transport.connected && !transport.devicePk`, read off a **ref** during render. `devicePk` is settled inside connect(), so every reconnect clears it and settles it again; a ref changing re-renders nothing, and nothing else announced it. So the panel went disabled on whatever unrelated re-render came next — a message arriving — long after the identity was actually lost, and had no event that would open it again. group-page.js never touches `status` after 'connected', and `onReconnected` is claimed by video-player.js, so there was no second chance. It was silent as well as sticky. `_announceDevice` had three exits that wrote `devicePk` without a word: two early returns that left the *previous* connection's value standing, and a reply that is not `device_hello_ack` — an `error` reply does not throw, so the `.catch()` at the call site never saw it. Reproduced in chat_send_probe.py, which mounts the real ChatPanel over the real transport: with the old code, identity cleared leaves the composer open, an arriving message latches it shut, and restoring the identity does not reopen it. Every write to `devicePk` now goes through `_setDevicePk(pk, why)`, which logs, traces and calls `onDeviceIdentity`; group-page holds the answer as state and ChatPanel takes it as `deviceReady`. Defaulting that prop to `true` fails open — a wiring mistake here must not be able to leave anyone with a dead textbox. Two things found on the same path and fixed with it. `_send` throwing inside _sendAndWait's executor left the pending entry and its 30s timer behind, so a request that never reached the wire still logged a "Response timeout" half a minute later. And the instrumentation this was meant to be diagnosed with (3be8bd2) writes to localStorage behind ?trace=1, not to the console, so the dump could not have carried it: the two lines that decide the composer's state are now logged unconditionally, and MeshBayTrace gains `record` so the composer writes into the same timeline as the channel events. Hub suite 2264 passed, 4 skipped. chat_send_probe.py gains a `reconnect` scenario and test_chat_send.py four cases, each checked against the unfixed source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
* feat(client): create the system tray at launch, not on first minimiseChristophe Besson2026-09-081-0/+33
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ensureTray() was reachable only from the window:minimize-to-tray handler, so the indicator did not exist until you had already hidden the window into it. That is backwards on both desktops — most of what a tray is for is finding an application that is not in front of you — and on Windows it read as the app having no tray presence at all. Created during app.whenReady(), after registerBridge() and before createWindow(). The order matters: buildTrayMenu reads the nodeService that registerBridge assigns, so the other way round puts the Start/Stop entry on the menu one five-second poll late. The menu's labels were the one thing that came *from* the minimise call, since the main process has no i18n. A new tray:labels IPC (platform.setTrayLabels) carries them instead, sent from the renderer's boot once initLocale() has a catalogue; a language change reloads the page, so the same call covers it. The window between launch and that first message shows TRAY_FALLBACK, in English. §5.10's two platform gates become one — trayOS() in main.js, which every tray path calls. test_desktop_shell.py's existing test is rewritten against it and two are added: the launch ordering, and that no tray path tests process.platform inline instead of calling the gate. Windows still files a new tray icon under hidden icons until the person drags it onto the taskbar. No API promotes it; documented in WINDOWS-PORT.md §5.11 rather than worked around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(hub): cap a directory zip at 512 MBChristophe Besson2026-09-081-1/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An arbitrary ceiling, not a technical one: the zip writer streams and holds one chunk plus a record per file, so it would happily produce a hundred gigabytes. Past half a gigabyte the honest answer is a subfolder at a time, or the files individually. Enforced in file-utils.js's downloadDirectory, which is the one implementation behind every zip button — Files' single folder, Files' multi-folder selection, and the Photos album button (docs/photos.md §3). - Per directory, not per selection: Files zips a whole multi-directory selection in one click, so an oversized folder is refused and its siblings still download. - Before _openDownloadTarget, so no save dialog opens for an archive that is never going to be written. - The bound is strict, so a folder of exactly 512 MB still goes through. - Counted in the 1024-based units formatSize already prints, so the number in the refusal is the number in the constant. group.zip_too_large in all ten catalogues. test_zip_size_limit.py runs the module under Node and pins the refusal, the inclusive bound, and that nothing is asked or started when a folder is over. The user guide's "a 40 GB folder costs 40 GB of disk" is no longer true and now documents the cap instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* fix(hub): stop badging every poster "unmatched" when TMDB is offChristophe Besson2026-09-081-0/+45
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The dashed warning outline and the "?" flag say a TMDB lookup ran and came back with nothing — that is how an operator finds the titles worth a "Fix match". With TMDB switched off for the group no lookup is made at all: media_meta_req answers confidence 0 for the whole library by design, so the poster grid drew every card as a failure of the very thing the operator chose, with no poster ever coming to clear it. Poster mode now shows the file's own thumbnail plainly in that case, the same as Flat does, and keeps the badge for what it was built for. - PosterCard takes tmdbEnabled and gates both the outline and the "?" on `tmdbEnabled && !confident`. The title still falls back to the parsed filename exactly as before, and the ready/imageReady anti-flash gating is untouched. - VideoDetailModal gates its "no confident match" paragraph the same way. A show still opens that modal with TMDB off — it is where the season list lives — so the card alone would have left the claim one click deeper. - Its operator-only "Fix match" / "Rematch" buttons are gated too: both act on a match that does not exist, one opening a search the node answers empty, the other dropping a cached match never made. Those buttons are the only entry points to TmdbSearchOverlay and doRematch. - Both props default to true, so a call site that forgets one keeps the badge rather than silently losing it. search-page.js is deliberately left passing `enabled: true`: a cross-group view has no single group's switch to read, and it never showed the operator buttons anyway (isNodeAdmin={false}). Recorded as §10.7 in docs/mediacenter.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(chat): Tier 2 — a member verifies another member's device itselfChristophe Besson2026-09-071-35/+77
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-072-2/+2
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-071-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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
* | feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)Christophe Besson2026-09-073-27/+863
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-0/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* chore(node): finish Phase 3 — CLI deprecations, Windows shapes, docsChristophe Besson2026-09-071-4/+67
| | | | | | | | | | | | | | | | | | | | | | | | | | | `member upload` reached the generic usage line for the other `member` verbs — "usage: meshbay-node member upload <username>" — which advertises a removed feature and sends the operator looking for a username it would then reject. It names `root set --writable` now, and the man page carries the same. Three lines between an operator finding the replacement and concluding the CLI is broken. `--upload-dir` still works, so an existing script keeps working, but its help and the man page say it is the old spelling and name what replaced it. The Windows pass (§4.4) is what can be checked from here, made checkable: drive letters and UNC through `as_posix()` into TOML, a drive root having no basename to derive a name from — sharing a whole drive is ordinary there — and a case-insensitive collision, which on NTFS and exFAT is one directory indexed as two roots. `PureWindowsPath` throughout, for the reason the backslash test earlier this branch got wrong. What it cannot check is written down rather than glossed: ReadDirectoryChangesW dropping events, MAX_PATH, and whether an eject actually lets a drive be removed. §7d says so, along with two things the plan never considered — the RO/RW asymmetry in `_do_dir_delete`, and `index_delta` carrying roots but not `dirs`. 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-2/+12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* docs(groups): the upload-policy migration is not optional after allChristophe Besson2026-09-061-2/+15
| | | | | | | | | | | | | | | | | | | | | §7c said the Phase 2 fallbacks made the migration script unnecessary. That is true of every setting but one, and the one it is not true of fails in the unsafe direction. `member_upload` is no longer consulted anywhere. A group whose operator had turned uploads off keeps a node.toml root saying `upload = true`, which reads as writable — so on the first restart after the upgrade that group accepts uploads from every member again, silently. It cannot be a fallback: "uploads are off for this group" and "this root is writable" are two sentences that happened to disagree, and only the operator knows which they meant. QE/migration/check_upload_policy.py (unversioned, per the QE/ rule) reads roster.db and node.toml, reports which groups are affected, and prints the `root set --no-writable` line for each. It writes nothing and exits non-zero when something needs a decision, so a deploy script can gate on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): Phase 2 — per-app settings panes, folder tree, multi-directoryChristophe Besson2026-09-062-6/+123
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz, and one folder picker per app, each with its own draft state and save handler saying the same thing about a different key. They are one file per app now, reached through the `apps.js` registry, and the page that renders them names no application at all: adding one is a registry entry and a settings file. The line between the two is what makes that true. What every app has — folders — the page does generically, through one `saveDirectories` bound to the app. What one app alone has, its pane does itself with the transport it is handed. An app that only needs directories touches neither `group-settings.js` nor `group-page.js`, which is `test_app_settings_plugin.py`'s subject. `settings-ui.js` exists because a pane importing the page that renders it is a cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at first render — a component that silently does not appear, the fault already recorded in CLAUDE.md about hook ordering. The flat depth-indented `<select>` of every folder in the library becomes a modal tree. It asks the node for nothing: the tree is derived from paths the client already holds, so it shows exactly what the group's index contains and adds no folder-browsing protocol. For Chat's attachment folder — the one directory that is written to rather than read — read-only roots are greyed out, so the node's refusal arrives before the operator picks rather than when somebody sends a file. Videos and Music take a list of folders. A library on two drives could not be described before; the only recourse was pointing the app at a parent containing both, which pulls in everything else under it. The scalar shapes survive on the wire alone, for a node speaking MNP 1.0, and the client reads them as a one-element list. Two things the tests caught that I would not have: `test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached through the registry rather than imported by name, they are exactly the files nothing else would notice changing, and a stale one is served from cache with no version bump. And `node --check foo.js` does **not** reliably report a module syntax error: it accepted `${/* ... */''}` — htm template syntax pasted into a plain object literal — and reported success. A `.mjs` copy forces the module parser and reports it. The suite had no syntax check at all, which is how that reached a file; `test_spa_syntax.py` does it for every module now, and pins that the loose path is not what it uses. Suite: 12 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-063-39/+177
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-0/+657
| | | | | | | | | | | | 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): indexing v2 — partial-read hashing for files above 40 MBChristophe Besson2026-09-061-0/+312
| | | | | | | | | | | | | | | | | Files above 40 MB are no longer read in full. Instead, blake3 hashes 45 MB of samples (first 20 MB + last 20 MB + 5 MB at 50% offset). Files at or below 40 MB are unchanged (full read, hash_version 1). A new `hash_version` field on IndexEntry (default 1) travels on the wire and through the cache so both versions coexist without breaking existing nodes or clients. The IndexCache auto-migrates its schema on open (ALTER TABLE), so no manual step is required on upgrade. A standalone migration script is available in QE/migration/ for operators who want to preview or force a full re-hash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(client): system tray on Windows, and a clearer tray iconChristophe Besson2026-09-051-3/+29
| | | | | | | | | | | | | | | The tray's Start/Stop already drove nodeService.status/stop/restart, which had full win32 branches for both startup modes from the Node page work -- so enabling it on Windows is widening two platform gates (the `tray` capability in preload.js, the window:minimize-to-tray handler in main.js), not new logic. Replaced the tray icon: the previous white chevron-in-a-box read as an envelope at tray size. New icon is a small "M" drawn as mesh nodes and edges, echoing the app icon's own motif, in the brand blue instead of plain white so it stays legible on both light and dark taskbars/panels. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(win): graceful shutdown, one startup-mode control, and a stray-\r bugChristophe Besson2026-09-052-44/+273
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Windows-only changes, all found by actually running the previous session's work rather than by review alone: - CTRL_CLOSE_EVENT/LOGOFF/SHUTDOWN handler (platform.py, ctypes SetConsoleCtrlHandler) so closing a console window, signing off, or a system shutdown runs the daemon's real _shutdown() instead of Windows just ending the process — closing WebRTC sessions and any in-flight ffmpeg transcode instead of orphaning it. `taskkill /F` itself stays uncatchable (like SIGKILL), so autostart_run() now spawns with CREATE_NEW_PROCESS_GROUP instead of DETACHED_PROCESS and autostart_end() tries CTRL_BREAK_EVENT against the recorded pid first, falling back to the hard kill only if that doesn't stop it in time. - Replaced the Node page's two independent autostart/service-mode toggles with one "start automatically" select (off / at sign-in / as a background service). The old pair let both be active at once — starting the daemon twice, at boot and at sign-in — and their layout broke wrapping inside .node-service's flex row. The new control always removes whichever mechanism is active before installing the target; platform.py's service_install() does the same on the CLI side. The "background service" option disables itself (with a hint pointing at the CLI) when running unpackaged, since service-mode.ps1/service.ps1/firewall.ps1 all assume an installed build's layout — verified live rather than assumed by actually running those scripts unelevated. - findNodeBinary() no longer bakes a stray \r into resolved paths. Found by rebooting after enabling per-user autostart: where.exe listed two matches, and stdout.trim().split('\n')[0] only strips the whole string's ends, leaving line one's own trailing \r attached — which landed inside the Startup .vbs's quoted path and broke it with "Unterminated string constant" at boot. Fixed by splitting on \r?\n and trimming every line. - Dependency audit for the Windows installer (docs/WINDOWS-PORT.md): no VC++ Redistributable needed, confirmed by inspecting the built node-runtime's actual import table rather than assuming. New docs/windows-build.md: a concise clone-to-installer build guide. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): bundle ffmpeg in the Windows installer by defaultChristophe Besson2026-09-052-6/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | winget install ffmpeg was considered and rejected as the mechanism: it needs network access and winget/App Installer present at the exact moment setup runs, and its failure mode is silent -- video just does not stream, with nothing pointing back at ffmpeg. Not viable for a non-technical install. MeshBay transcodes browser-incompatible video to H.264 (-c:v libx264, webrtc_server.py) -- a real encode, not remux -- so this needs a genuine GPL ffmpeg build; no LGPL-only build includes an H.264 encoder, since libx264 itself is GPL. packaging/win/fetch-ffmpeg.ps1 (new) Downloads, checksum-verifies and stages ffmpeg for the build. Source: BtbN/FFmpeg-Builds' Windows x86_64 gpl-shared preset -- shared DLLs rather than two independent static binaries, which is what nearly tripled this: the "full" static build many devs already have via winget is ~220 MB *per executable*. Pinned to one dated release tag (immutable once published) and its own sha256, not the "latest" alias BtbN repoints on every auto-build -- verified by hand first (downloaded, hash matched, ran a real encode+probe with libx264) before pinning. ffplay.exe (an SDL2 player, ~17 MB) is dropped; MeshBay never invokes it. Cached after the first build. Runs its own smoke test (encode + probe a real clip) so a broken fetch fails at build time, not for the first user who tries to watch something. packaging/win/LICENSE-ffmpeg.txt (new) GPLv3 notice + where the corresponding source is, required because this redistributes a GPL binary even though it is unmodified and only ever invoked as a subprocess. Ships alongside ffmpeg.exe in the installer. build-node-runtime.ps1 / build-win.ps1 Bundling is now the DEFAULT, replacing the old opt-in -FfmpegDir (which copied from a local directory and left most builds without ffmpeg at all). -SkipFfmpeg opts out for a smaller, streaming-less local-iteration build. Also fixes a real bug the ffmpeg change exposed rather than caused: the final `--help` smoke test did `$help -notmatch "meshbay-node"` against $help captured as a PowerShell ARRAY (one element per line) -- -notmatch on a collection is a FILTER, not a boolean test, and returns the non-matching elements; any non-empty array is truthy in if() regardless of content. Once --help wrapped past one line (it now does, with autostart/service in the verb list) this threw unconditionally. Fixed by joining to one string before matching, and pinned by a new test so a future edit cannot silently reintroduce the collection-vs-scalar trap. Verified: downloaded and hashed the pinned release by hand (matches), ran a real libx264 encode + ffprobe against the extracted build, fetch-ffmpeg.ps1 end to end (161 MB staged), a full build-node-runtime.ps1 run (308 MB node-runtime/) and a full installer build (MeshBay-Setup- 1.0.0.exe, 210.8 MB with ffmpeg bundled). Node suite 850 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: opt-in Windows service mode (boot-time, one elevation) + v1.0.0Christophe Besson2026-09-041-10/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-user Startup-folder launcher (W3) only ever runs after this user signs in. A real Windows Service would start earlier, but under LocalSystem/NetworkService -- accounts with no normal profile, so %LOCALAPPDATA%\meshbay\ (config, keystore, data) would not exist for it. Relocating storage to make that work is real surgery, deliberately not done here. Instead: a Scheduled Task, created once with admin rights, that runs AS THIS USER at boot without needing them to sign in first. `schtasks /create ... /ru <user> /rp ""` with no `/it` registers an S4U (Service For User) logon -- no password stored anywhere, and unlike LocalSystem it loads this account's own profile, so config_dir()/ data_dir() need zero changes. The cost: S4U carries no network credential, which the node never needed -- everything it touches is local disk plus outbound internet. Creating the task needs admin (a boot trigger touches system-wide scheduler state, the same reason /sc onlogon needed it); querying/starting/stopping an existing one does not -- Task Scheduler grants the owning user that much itself, which is what lets the Node page's Start/Stop/Restart drive it with no further UAC prompts. meshbay_node/platform.py service_install/_remove/_status/_run/_end -- mirrors autostart_* but for the Scheduled Task; TASK_NAME moved here (was decorative before) meshbay_node/daemon.py new `service install|remove|start|stop|status` verb; restart-daemon and reset now check for the service task too packaging/win/service.ps1 the installer-side equivalent (extraResource); status/run/end never self-elevate -- only install/remove do, exactly matching what Task Scheduler itself requires packaging/win/service-mode.ps1 ONE elevated helper running service.ps1 + firewall.ps1 together, so choosing service mode costs exactly one UAC prompt, not two build/installer.nsh the install-time choice: "run as a background service?" (one elevation, both jobs) vs the existing per-user + separate firewall question. Checked first, unelevated, so re-running setup with everything already configured asks nothing. Uninstall offers the matching one-elevation cleanup, default No. src/main.js winServiceTaskStatus/Run/End, wired into node:installed, node:service-status/-stop/-restart and node:start: when the Scheduled Task exists, drive it; otherwise fall back to the existing per-user spawn/kill path. This is the hard requirement -- Start/Stop/Restart from the Node page must work in either mode. node-page.js / locales a hint explaining why the per-user autostart toggle is absent when service mode is active (info.mode from the backend, no new field to gate on -- it just isn't sent in that case) package.json: 0.1.0 -> 1.0.0. Verified: electron-builder compiles the new NSIS choice logic and ships all three scripts; service.ps1's S4U install fails cleanly (Access denied) when run unelevated, and its status/run/end never touch "runas". Cannot verify the elevated success path myself (no admin in this session) -- that needs a real UAC click. Node suite 843 pass / 25 skip; test_packaging_win.py pins the one-elevation property, the S4U flags, and that main.js actually checks the service task in all three handlers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): offer one elevated firewall step instead of two dialogsChristophe Besson2026-09-041-3/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Installing used to mean clicking through two separate Windows "Allow access" prompts later — one for MeshBay.exe, one for meshbay-node.exe — each confusing on its own and worse before the exe carried a version resource. Adding a firewall rule needs admin, and the installer is deliberately per-user with no elevation, so this can only ever be opt-in. packaging/win/firewall.ps1 (new, shipped as an extraResource at resources\firewall.ps1): idempotent add/remove of the two inbound UDP rules ("MeshBay", "MeshBay Node"), grouped, logged to %TEMP%\meshbay-firewall.log. Locates both executables from its own path, no arguments needed beyond the action. build/installer.nsh: customInstall asks "Allow MeshBay through Windows Firewall now?" and runs firewall.ps1 via NSIS ExecShellWait "runas" — one UAC prompt — only when not ${Silent}; declining or dismissing UAC falls back to Windows' own per-process prompts, unchanged. customUnInstall offers the same in reverse, defaulted to No (a stale rule for a deleted exe is inert, so this should not nag on the way out) and skipped for a silent uninstall. Verified: rebuilt MeshBay-Setup-0.1.0.exe (electron-builder compiles the new LogicLib.nsh / ExecShellWait NSIS successfully); firewall.ps1 run unelevated fails cleanly into its log ("Access is denied") rather than silently doing nothing, confirming the fallback path. Node suite 835 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: drop the retired Mozilla STUN server from the defaultsChristophe Besson2026-09-041-4/+8
| | | | | | | | | | | stun.services.mozilla.com no longer resolves — Mozilla shut the service down — so every ICE gather waited out a DNS timeout on it. Removed from the node defaults (config.py), the browser defaults (transport.js) and the Node page's "reset to defaults" (node-page.js). Google (two endpoints) plus Cloudflare still give two-provider coverage against a single outage, which is the §2.12 resilience claim. draft-v6 §2.12 updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): ship a node firewall profile for inbound WebRTCChristophe Besson2026-09-041-0/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | The packages carried a profile for LAN casting and none for the node's own peer traffic, on the reasoning that the node exposes only a loopback admin API. That is true of its administration surface and false of its transport. WebRTC binds an ephemeral UDP port per connection, so there is no fixed port to open, and a connection succeeds only if one side can initiate. Browsers publish their host candidate as an mDNS `<uuid>.local` name, which aioice cannot resolve on any platform and discards — so the node can never call a browser back, and the browser must call the node. A node that refuses unsolicited inbound UDP is unreachable from every browser on its own LAN, leaving reflexive candidates, which fail whenever both peers share one public IP and the router will not hairpin. Hit twice in one session on two different hosts: a firewalld zone narrowed to mdns + 19550-19553/tcp, and a ufw host with default deny-incoming. Both presented as "the app cannot connect", neither as a firewall message. Passive like the cast profile: packaged, not activated. The guide says to scope it to a LAN zone or source, including the libvirt case, where traffic from a guest to its own hypervisor is not masqueraded and so must be scoped to the guest subnet rather than the LAN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(node): make ice_interfaces match adapters on Windows (W9)Christophe Besson2026-09-041-1/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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
* feat: Windows installer (W4) — one per-user NSIS package, client + nodeChristophe Besson2026-09-042-40/+97
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `npm run dist:win` produces MeshBay-Setup-<version>.exe: the Electron client and, beside it under resources/node-runtime/, the frozen meshbay-node daemon (meshbay-common inside it). No hub. Per-user, no elevation — matches the W3 constraint that a logon-triggered scheduled task needs admin. electron-builder / package.json build.win nsis, build/icon.ico, extraResources -> node-runtime/ build.nsis oneClick:false perMachine:false allowElevation:false allowToChangeInstallationDirectory:true dist:win -> packaging/win/build-win.ps1 (mirrors dist -> build-client.sh) packaging/win/ meshbay-node.spec + node-entry.py PyInstaller freeze of meshbay_node.daemon:main. The awkward deps (aiortc, av, aioquic, pydantic_core, uvicorn, watchdog, guessit, blake3, tzdata) are pulled in whole with collect_all — that list is expected to grow when a frozen run raises ModuleNotFoundError. build-node-runtime.ps1 throwaway venv -> pip install -> PyInstaller -> packages/meshbay-client/node-runtime/ (gitignored) build-win.ps1 Node>=22 check, npm ci, Electron bump, sync-ui, node runtime, electron-builder --win nsis bump-electron.mjs the Chromium-CVE "build against latest Electron" policy, out of the PS script (5.1 here-string terminator rules) README.md PyInstaller, not the python-embed zip: the frozen meshbay-node.exe is a genuine relocatable single binary, which is what src/main.js:findNodeBinary spawns (process.resourcesPath/node-runtime/meshbay-node.exe when packaged) and what the W3 autostart launcher points at. The embeddable zip needs pip to make that wrapper and the wrapper bakes in an absolute interpreter path. build/installer.nsh: on uninstall, taskkill meshbay-node.exe and delete the W3 Startup .vbs (it would point wscript at a deleted binary every sign-in). %LOCALAPPDATA%\meshbay\ — node.toml, keystore.enc — is never touched. ffmpeg is not bundled by default (node finds it on PATH); build-win.ps1 -FfmpegDir copies ffmpeg.exe/ffprobe.exe in for a self-contained installer. Verified on the Windows guest: PyInstaller freeze builds first try (node-runtime 147 MB), frozen `meshbay-node status` talks to the live daemon's loopback API; electron-builder --win nsis produces MeshBay-Setup-0.1.0.exe (155 MB), oneClick/perMachine flags applied, node-runtime bundled at the path findNodeBinary expects. test_packaging_win.py (14) pins the config invariants and the NSIS <-> platform.py autostart seam. Node suite 798 pass / 34 skip. Open: Authenticode signing (13.9 — unsigned => SmartScreen), Windows CI (18.3), electron-updater. First clean-machine install + DPAPI + autostart round-trip is a manual check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat!: MNP 1.0 — seal index and handshake_ack under the group keyChristophe Besson2026-09-031-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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
* docs: Windows port audit and sender key distribution decisionChristophe Besson2026-09-032-33/+535
| | | | | | | | | | | | Add docs/WINDOWS-PORT.md with the full portability audit (what is already portable, what blocks, implementation plan W1-W7). Reverse structural decision 20: sender keys are distributed GEK-wrapped, not pairwise to identity keys. The GEK is the group secret; files and chat share the same access boundary. Per-device chains (15.0b) remain required for correctness. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub): stop the maintenance loop racing the tests, and pin _ASSETSChristophe Besson2026-09-025-4/+146
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects found while closing out the Search merge, neither of them in that feature. The maintenance loop. create_app's lifespan starts cleanup_loop as an asyncio task, so every test — each entering that lifespan — ran a purge pass concurrently with its own requests. On SQLite :memory: that is not merely noisy: the engine uses a StaticPool, one connection for the whole process, so the request's session and the cleanup task's session interleave transactions on the same connection. A registration could commit and then be invisible to the login three lines later, surfacing as 401 Invalid credentials for an account created moments before, in roughly one run of test_node_ws_auth.py in four. The purge itself is not at fault and this is not a production condition. A passive SQL listener caught the DELETE removing 0 rows, and the INSERT carrying status='active' — so neither the pending-account mechanism nor the purge filter is involved, and PostgreSQL gives every session its own connection. What the fixture removes is the second user of the shared one. 60 runs of the previously flaky file, 0 failures; reproductions before the fix landed on attempts 4, 6, 13 and 29 of separate loops, so a clean run of 60 has about a 1% chance of being luck. _ASSETS. source-merge.js shipped missing from webapp._ASSETS, the cache-busting hash's input list — exactly the silent failure docs/apps.md §4 step 5 warns about: the file changes, the asset URL does not, and a browser holding the old page keeps the old copy. Harmless this time only because search-page.js changed in the same commit and is listed, which is the worst way for it to go unnoticed. Found by re-reading that checklist for the doc pass, not by any test — so there is a test now, holding _ASSETS to every .js in static/ (sw.js excepted, unversioned on purpose). It was the only one missing. Phase 9 of docs/refactoring-search.md also lands here: mediacenter.md §10.6, musicbay.md §9b, photos.md §10b, apps.md §2b and step 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(hub): merge duplicate sources in Search's Music and Photos tooChristophe Besson2026-09-021-23/+43
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phases 5-8 of docs/refactoring-search.md, extending the Videos merge outward. A library shared by two groups now lists each track once inside an album and each photo once inside a photo album, and a card served by several groups says "N sources" instead of naming one of them. Units come from each application's own grouping, never a copy of its keys. For Music that meant exporting foldKey: groupMusicEntries folds case to group but keeps the first-seen spelling to display, and which group is seen first is whichever index arrived first — so keying a unit on the display strings would let the chosen source change between page loads. A group whose connection fails is marked down and stops being chosen, so a unit fails over to another group that has the file. Eviction is not a failure. Every source being down still yields an entry: a tile that fails to load beats a film that vanished from the grid. sourceLabel now takes the whole unit rather than one entry. A show's poster entry is picked for its thumbnail, so a show in two groups whose cover episode sits in only one of them would have claimed a single source. SourceTag lives in group-name.js — source-merge.js must keep importing nothing (its test executes it standalone), and a copy in each of the three apps is three chances to disagree. test_search_files_unmerged.py holds the one thing that must not change: the Files explorer is not merged, because there each group is a folder and merging would remove a file from one of them. It also asserts the other three lists are merged, or deleting the merge outright would leave it passing and saying nothing. One plan item was dropped as wrong rather than built: the Music queue in onPreview needed no change. It filters by groupId and is reachable only from FilesPanel, which is not merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(hub): one entry per file in the Search view's Videos gridChristophe Besson2026-09-021-6/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A library shared by two groups arrived in the cross-group Search view as two entries per file: every film was two poster cards, every episode was listed twice in the season list under the synopsis. Inside one group this cannot happen — GroupIndex is keyed by blake3 — so the duplication was the Search page's own, from concatenating N independently keyed indexes. source-merge.js folds entries on the content hash and resolves one source per *unit* (a film, a whole show), so a season does not scatter across two nodes. A group hosted by the reader's own node wins; failing that the pick is a hash of the unit key and the reader's id, stable across renders and reloads — a source that changed mid-stream would tear down the connection under a film that is playing — and spread across readers and units. The units come from video-app.js's own groupVideoEntries rather than a second copy of its keys here. Only the Videos view is wired up so far; Music, Photos, failover and the "N sources" badge are phases 5-8 of docs/refactoring-search.md. Every test was checked against the fix removed. That is how the first version of "a unit's files share its source" turned out to prove nothing: with every episode in every group, per-file and per-unit picking give the same answer, so it passed against a per-file implementation. It now uses a unit whose files have unequal sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* docs: plan for merging duplicate sources in the Search viewChristophe Besson2026-09-021-0/+376
| | | | | | | | | | | | | | | | | A file shared by two groups is two entries in the cross-group Search view: one film shows as two poster cards, one episode twice in a show's list, one track twice in an album. Within a group this cannot happen — GroupIndex is keyed by blake3 — so the duplication is created by the Search page concatenating N independently keyed indexes. The plan: merge on the content hash, choose one source per logical unit (film, show, album), prefer a group hosted by the local node, otherwise pick deterministically per user, and fail over when the chosen source is unreachable. The Files explorer stays navigable per group and is not merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(hub): dismissing a notification deletes itChristophe Besson2026-09-021-4/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit filtered the list to unread, which corrected what the reader saw and left every dismissed row in the table, invisible for ever. That is a place to hide the disagreement rather than a resolution, and the operator said so: "elles s'accumulent pour rien." So dismissing drops the row. It is the reasoning `purge_notifications` has carried all along — "these are signals, not a record: the group is still there, the message is still in the chat, the invitation is still an invitation" — applied one at a time instead of only in bulk. - `DELETE /v1/notifications/{id}` is the honest name and what the SPA calls. - `POST /{id}/read` reaches the same handler and now deletes too. It has to keep working: the interface ships inside the desktop package, so a hub is always answering some client older than itself, and giving the old path the new behaviour means those clients stop hoarding as well rather than only the updated ones. - `read-all` deletes rather than marking, which makes it `DELETE ""` under an older name. Marking would have made it the one route still filling the table. Nothing in this repo calls it, but a reachable endpoint is one that can be called. `Notification.read` is now vestigial — nothing stored can be read, because reading it deletes it. It stays because dropping a column is a migration for no gain, and `unread_only` stays because a SPA newer than its hub still needs it to be right. Both are said in the module docstring rather than left to be worked out. Two existing tests encoded the old semantics and now assert the opposite; test_notification_dismissal.py gains one for the old `/read` path, because version skew is the normal case here and not the exception. 617 hub tests pass. docs/USERGUIDE.md's endpoint table updated in both places it lists them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): a desktop solve reports no hostname at all, not "meshbay"Christophe Besson2026-09-021-18/+41
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Registering from the native client failed with `captcha_failed` while the checkbox was green — a worse symptom than the one being fixed, because the widget now looked fine and only the hub's own log said otherwise: captcha solved on an unexpected host ''; allowed: ['localhost', 'meshbay', 'meshbay.org'] The previous commit assumed Google would report the host component of the origin, so `app://meshbay` would come back as `meshbay` and could sit in `allowed_hosts`. It does not. A solve Google cannot attribute to a domain reports an **empty** hostname, and no allowlist entry can match that. An empty entry is not the answer either: a blank in a TOML list is a typo far more often than an intention, and `load_config` drops blanks for that reason — `captcha.allow_unattributed_host` is a named flag instead, so the trade is stated where it is made. What it admits, plainly: every non-web client, not only ours. A file:// page or somebody else's Electron application look identical from here. That is the same bar the client's own origin would have been — main.js already records that `app://meshbay` is not a credential — and it is a bar: the captcha still has to be solved, per token, in something that can render it. What is given up is the origin restriction for non-web clients, not the captcha. Off by default, and a hub without the desktop client should leave it off. The refusal now names which of the two it is, since they need different answers: an unexpected host names the host, an unattributed one says to set the flag. docs/captcha.md §6 said `meshbay` was the value and told operators to add it; it now records what was measured and why the guess was wrong. The packaged example config carries the flag with the same warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): check the captcha's origin here, so the desktop client can pass oneChristophe Besson2026-09-021-39/+71
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported from the native client: the reCAPTCHA box renders "ERROR for site owner: Invalid domain for site key". The web browser is fine. It is not a client restriction, and the CSP was never what refused — the script loads, which is why the widget appears at all to say so. reCAPTCHA validates the hostname of the page the widget is rendered in against the domain list on the site key, and the desktop client's interface ships inside the package and is served from `app://meshbay` (main.js: `win.loadURL`). Not a preference: file:// breaks ES modules and IndexedDB, and the hub must never become the document origin. So the hostname Google sees is `meshbay`, it is not on the key's list, and it never can be — the check runs on Google's servers and nothing client-side reaches it. The fix turns that check off on the key and does it on the hub instead: [captcha] allowed_hosts = ["meshbay.org", "localhost", "meshbay"] `verify_captcha` refuses a solve whose hostname is not in the list. The hostname comes from `siteverify` — what Google observed, not what the caller asserts — so it is a real check against what turning the console setting off opens, which is a bot rendering the public site key on a page of its own. Empty (the default) skips it, so an existing hub upgrades unchanged with reCAPTCHA still doing the origin check. The two settings go together, and docs/captcha.md §6 says so. The `meshbay` entry is the weak one and the doc says that too: any Electron application can claim the same scheme and host, as main.js already records. What it still costs is a captcha solve per token inside a real Chromium instead of a token farmed from any web page. docs/captcha.md §6 replaced. It documented a design that was superseded twice — an `auth_key`-keyed carve-out that turned out to disable the gate for everyone, and "works in the Electron client too, both run Chromium", which is the assumption this bug is made of: reCAPTCHA validates the domain, not the rendering engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): a show opens on its first season, not its first thumbnailChristophe Besson2026-09-021-2/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | Reported live: a show with a dozen seasons opened on season 6. Every season was in the picker and none was missing — the default was wrong. VideoDetailModal took it from `repEntry.season`. `repEntry` is the show's "representative entry", which the poster grid picks as `episodes.find((e) => e.thumb_hash) || episodes[0]`: the first episode that has a thumbnail, so the card has a fallback frame when TMDB has no poster. That is from the original Videos commit; the season tabs came later and read the same entry as "the episode the reader is looking at", which it never was on that path. Episodes are sorted by (season, episode), so a show whose first five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg failing on those files — hands back a season-6 episode. `defaultSeason(show)` reads the season list and nothing else: the lowest season present, specials only when there is nothing else, and the lowest *number* rather than the first entry so it does not quietly depend on buildSeasons keeping its sort. The effect's dependency on repEntry goes with it — nothing in it reads that any more. test_video_default_season.py runs the function in node. No input it takes can carry a thumbnail, which is the point. docs/mediacenter.md §10.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): the show detail modal must not move when the season doesChristophe Besson2026-09-021-8/+35
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous pass fixed the synopsis and the cast, and the dialog still jumped: the episode count moves things a fixed-height synopsis cannot reach. - The body scrolled as a whole, so a thirteen-episode season pushed the modal to its max-height where a six-episode one had not. `.video-overlay` centres its child, so the taller modal also *started higher up the screen* — title bar, close button and all. `.video-detail-steady` (a multi-season show only) gives the modal a height rather than a max-height, makes the body a flex column, and hands the leftover to the episode list as the one scrolling part. A constant-height box is centred in the same place every time, so both halves settle at once. - A scrolling season draws a scrollbar where a non-scrolling one draws none, which is a scrollbar's width of content and re-wrapped the file path above it, shifting everything below by a line. `scrollbar-gutter: stable`. - The season panel was clipped by the modal's own `overflow: hidden` whenever the seasons outran the room under the picker — at a 740px viewport it wanted 320px and had 288, and the rest sat where no scroll could reach it. It is `position: fixed` now, placed by `placeSeasonPanel()`, which takes the trigger's rect and the window height, picks whichever side has more room, and caps the panel to it. Scoped to multi-season shows throughout: a movie has no season to switch to and a fixed height would buy it nothing but empty space. test_video_detail_measured.py now builds each block inside a real `.video-overlay`, since the centring is half the defect, and asserts the modal top and height as well as the picker's offset — for a long and a short synopsis and for a six- and a twenty-four-episode season. test_season_panel_placement.py runs placeSeasonPanel() in node over a rect and a window height. Two guards are declarations rather than rectangles and say so in their docstrings: headless Chrome gives the probe zero-width overlay scrollbars, so the gutter cannot be measured there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): steady the show detail modal, and give a series its directorChristophe Besson2026-09-021-5/+63
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* revert(hub): M6 — add_group_member must keep accepting node tokensChristophe Besson2026-09-011-25/+36
| | | | | | | | | | | | | | | | | | | | | | | | | M6 in the third review was a misread. `add_group_member` accepting a node-scoped token is deliberate (commit 0443cf8): the node calls POST /v1/groups/{id}/members/{username} after a CLI `member invite` so the group shows up in the invitee's SPA, authenticating with a node-scoped token. `group.admin_id == caller` is the real guard. An older test (`test_node_scope_blocks_add_member`) asserted the opposite and had been left red on main; the M6 "fix" (commit 6b38704) satisfied that test by switching the dependency to `require_user_scope` — which made `ops.create_invite`'s hub-membership call 403. That exception is swallowed with a log.warning, so an invited user silently never lands in group_members and the group is invisible to them. Reported from live testing (CLI `member invite grenet`, grenet saw nothing). Dependency back to `get_current_user`. The stale test now asserts the intended behaviour: a node token may add a member to its own operator's group (201) but not to a group it does not own (403). Third-review M6 marked WITHDRAWN. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* docs: mark M4 and M5 fixed in the third security reviewChristophe Besson2026-09-011-25/+56
| | | | | | | | | | | | | | | M4: federation `source_hub` bound to the token signer, push capped, revocation prunes the peer's own directory entries, state-changing MHP tokens are single-use. M5: a middleware adds a CSP and the other protective headers to every response, matching the desktop client's policy for these files. Every finding in the review (H1, H2, M1-M6) is now fixed; the summary, findings table and action plan reflect that. Original finding texts kept for the record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* docs: mark M3 fixed in the third security reviewChristophe Besson2026-09-011-15/+38
| | | | | | | | | | Link-preview SSRF surface bounded: per-connection + node-wide rate limit, port allowlist, connect-address re-check, decompression-bomb guard. Summary, findings table and action plan updated; original M3 text kept for the record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* docs: mark M2 fixed in the third security reviewChristophe Besson2026-09-011-27/+40
| | | | | | | | | | QUIC chat/stream handlers brought to WebRTC parity, and the QUIC listener gated off by default. Executive summary, findings table and action plan updated; the original M2 finding text is kept for the record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* docs: add third security review (2026-09-01)Christophe Besson2026-09-011-0/+635
| | | | | | | | | | | | | | | | | Code-level review focused on what changed since second-review.md: the unified handshake, device linking, account recovery, email verification, reCAPTCHA, the hub instance-policy store, MHP federation, the relay registry, chat link previews, and the node's loopback control API. The second review's critical/high list is confirmed closed. New findings H1, H2, M1 and M6 are fixed in the preceding commits and annotated as such; M2 (QUIC chat handlers regress NS6/H1/H6), M3 (link-preview SSRF), M4 (federation trust), M5 (no SPA CSP) and the L-list remain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* docs: move root docs into docs/ and archive superseded draftsChristophe Besson2026-09-0114-5445/+7264
| | | | | | | | | | | | | | | | | | Move the remaining root-level .md files (except CLAUDE.md) into docs/: devel-phases.md, devel-phases-next.md, first-review.md, second-review.md, tmp-decisions.md. Update all inbound references in CLAUDE.md (now docs/-prefixed) and strip the now-redundant docs/ prefix from links inside the moved files. Consolidate the superseded material into docs/old-draft.md: architecture drafts v1-v4, POC v1, and the Phase 1-12 development log, each under an ARCHIVED banner with a preamble pointing at the current specs. Delete the merged originals plus the unreferenced French translations (v1-fr, v2-fr, poc-v1-fr). Repoint the surviving file-links in first-review.md, second-review.md and meshbay-draft-v5.md at old-draft.md; prose "draft-v3 §x" mentions are left as-is since the content now lives in the archive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J74kj44q6REczub8XR3DRy
* refactor(node): JSON-only control API, Node page absorbs the admin dashboardChristophe Besson2026-09-015-24/+434
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
* feat(hub): reCAPTCHA v2 on Register and Password Reset pagesChristophe Besson2026-09-011-0/+490
| | | | | | | | | | Server-side verification module, CaptchaConfig in hub.toml, captcha_site_key exposed via /v1/hub/info, useCaptcha() hook in the SPA with stable DOM rendering (strength bar always present to avoid Preact re-ordering the captcha widget). Native clients (auth_key path) skip captcha. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>