aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
Commit message (Collapse)AuthorAgeFilesLines
...
* Merge origin/main into the chat encryption workChristophe Besson2026-09-072-70/+194
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-072-29/+92
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-19/+0
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `stream_seg` answered with an MPEG-TS segment as base64 with no encryption at all — the one message on the content plane that never went through a GEK-derived key. Live on both transports, answering any authenticated member. It predates `stream_data`, which does the same job properly (`chunk_ciphertext`, keyed per segment, AES-256-GCM) and has since Phase 12. Its only browser caller, `fetchStreamSegment`, was defined and never once invoked — a plaintext media endpoint with no client. Removed rather than repaired. Gone with it: `_extract_segment` and the ffmpeg semaphore in quic_server, the `fetch_stream_segment` QUIC client method, and `_b64decode` in transport.js, which had no other caller. The H6 regression test lived on this handler — it pinned `_do_stream_segment_async` to a coroutine so `subprocess.run` could not stall the event loop for thirty seconds per request. It is replaced by the property that outlives the handler: no transport carries media outside an AEAD, asserted on `stream_seg` and `data_b64` across all three transport modules. The half of H6 that survives — the live streaming path still spawns ffmpeg — keeps its own test. BREAKING CHANGE: `stream_seg` is no longer answered on either transport. No shipping client sends it. Recorded as part of MNP 2.0, whose other half — the sealed upload — carries the version bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
| * fix(mnp): give a reply an id, so it stops being routed by luckChristophe Besson2026-09-071-11/+88
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | MNP carried no correlation id. A reply named its own type and nothing else, so a client with more than one request in flight worked out which one a message answered from the message itself — and for the replies that name nothing it could not. `_dispatch` fell through to matching by arrival order, which is a guess. `_sendAndWait` had the right value all along: it keys `_pending` by `this._seqId++` and never put it on the wire. The guess fails asymmetrically, which is why it hid. The victim is not the request that was answered wrongly — it is the unrelated one that now waits out its own 30s timeout for a reply already delivered elsewhere. Live on 2026-09-06: five `music_meta_req` sat pending for over 100 seconds behind a failing MusicBrainz, and a `device_list_result` was handed to one of them. The composer is disabled while a send is in flight, so a chat message whose reply went astray the same way left the Chat tab looking frozen for thirty seconds, then unfroze on its own. The `ack` half of this was fixed on 2026-08-30 by matching on request type. That closed the instance and left the class open: a refusal has no type to match on either, and `_dispatch_message`'s catch-all answers every unforeseen failure with `{"type": "error", "detail": "Request failed"}` — 238 of this module's 240 error sends name nothing at all. `req_id` now rides on the request and comes back on the reply. On the node it is published for the whole handler in a ContextVar and stamped by `_send`: a parameter would have meant threading an argument through all 240 send sites, and asyncio copies the context into a task, so a handler that `_spawn`s its real work still answers under the right id. It is never stamped on a broadcast — those answer nothing, and the owner check in `_send` is what keeps a chat broadcast or an index push from reaching another peer looking like a reply. On the client, `_dispatch` resolves on `req_id` first and the arrival-order fallback is gone the moment a node proves it stamps (`_correlates`, armed by the handshake's own reply). The fallback stays for an MNP 1.0 node, unchanged and no wider: there it is the only thing there is, and removing it would leave device_list_result, join_result and the handshake replies reaching nobody. Two things fall out. `sendChat` refuses an `error` reply like every other request in the file — it returned it as success, which did not matter while a refusal reached the wrong caller anyway and would now show a rejected message as sent. And `_group_ctx` uses `.get`: a reload pops a removed group while sessions connected to it are open, and every request they had left raised KeyError into that same catch-all. Sealed index messages are the one exception to the fast path. They cannot be handed over until they are opened, which is asynchronous while `_dispatch` is not — resolving on the id alone gave `fetchIndex` the envelope and skipped `onIndexSync` entirely. Caught by extending `index_seal_probe.mjs` to stamp a reply the way a current node does, after the hub suite passed over it: the probe built its own frames and had never seen one. Tests, all failing before and passing after: `test_chat_send.py` drives the real ChatPanel over the real transport for both shapes of reply with an older request pending (3 of its 6 are new, and the 3 for `ack` pass either way, so it discriminates); `test_reply_correlation.py` pins the node's half — the refusals that name nothing else, the broadcast that must not be stamped, and a late reply from a spawned task answering under its own id rather than the most recent request's. Full suite: 1897 passed, same 11 pre-existing failures as before. QUIC keeps its own dispatch and is not stamped. It is disabled by default and no browser request reaches it, but the asymmetry is real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dn1xYx9uT69mCB6UDvyKAN
* | feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)Christophe Besson2026-09-0715-26/+656
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(client): drop the "No changes to save" hint beside SaveChristophe Besson2026-09-0715-28/+0
| | | | | | | | | | | | | | | | | The hint was added alongside the .app-save control, when an inert Save was indistinguishable from an enabled one and the reader had no way to tell "nothing changed" from "this is broken". The control now reads as disabled on its own, so the sentence beside it is noise. Removing it also removes what only existed to carry it: the .app-save-row wrapper, its two style rules, and settings_app.no_changes in all ten locales. test_the_disabled_state_is_visually_distinct sliced the stylesheet on .app-save-row; it now stops at the closing brace of .app-save:disabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): Save in the app panes was invisible, and Chat's never landedChristophe Besson2026-09-0716-17/+95
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects behind one report — "you cannot always click Save, you do not notice, and it does not work". **It did not work, for Chat, systematically.** `_dispatch` resolves an admin ack against the pending request and returns, which is right for an op whose caller already knows the value it chose. Chat's pane calls `transport.setChatDirectory` itself, so nothing told `group-page` anything: the node saved it, every *other* connected client learned it from the broadcast, and the one that asked went on showing an unsaved-looking draft. Clicking Save again just re-sent it. Same shape as the root-ack bug, in a different message — so the set is now `BROADCAST_ACK_TYPES`, named for the property that makes it true, and covers `chat_directory`, `chat_link_preview` and `app_directories`. **You could not notice, because Save was not visibly a button.** It carried `btn btn-small btn-secondary`, and there is no `.btn` rule in the stylesheet at all — so it took `.btn-secondary`: no background, a transparent border, dim grey text. Enabled it already looked like a disabled control; disabled it was the same thing at 40% opacity. Measured on a real page: enabled is now accent on white, disabled is grey text on a plain border, and an inert one says why ("No changes to save") rather than leaving the reader to guess what the pane counts as a change. Videos' second Save — the TMDB one — is the same control, because two Save buttons in one pane that do not look alike is worse than either looking wrong. Verified by driving the real pane in Electron through the whole cycle: inert, pick a folder, live, save, and the node's answer coming back to disable it again. Four earlier readings said the enabled button was transparent; all four were taken inside python's directory-listing page, which the app never runs in — my scaffolding, not the code. Hub suite only: the node package is untouched by this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): HelloWorld, the reference applicationChristophe Besson2026-09-0717-14/+254
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every other test of the plugin architecture reads source for the *absence* of app names. That proves nobody wrote a special case for Videos; it cannot prove a genuinely new application works, because there was no new application. This is one. It stores directories, appears as a tab, has a settings pane and lists files, and the node has never heard its name outside a single allow-list entry. Two files and one registry line, which is the claim `docs/refactor-groups.md` §4.1 makes. It ships hidden behind `?dev=1` (`dev: true` in the registry, the same opt-in shape as transport.js's `?trace=1`). Registering it normally would put a toy app in every operator's group; not registering it would prove nothing, since registration is exactly what is claimed to be sufficient. **Adding it found two places where the claim was nearly true rather than true, and both are fixed by making the code less app-specific:** `group-settings.js` fell back to the whole registry when a group had no `enabled_apps` yet — which would have turned a hidden app on for everyone. It asks `availableApps()` now. `group-page.js` wrote out `videoDirectories` / `musicDirectories` / `photoDirectories` by hand, so a fifth app would have needed that file edited. It derives `<key>Directories` from the registry. Neither was found by reading; both were found by adding the app, which is the whole reason it exists. Verified in a real Electron window as well as by the tests: hidden by default, present with the flag, offered its own settings section, and listing exactly the files under its configured folder and its subfolders — not the ones beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): report a directory result under the button that caused itChristophe Besson2026-09-071-9/+23
| | | | | | | | | | | | | | | | | | The message was the first thing in the section, wedged between the intro and the table header — above everything the eye has already moved past by the time it appears. It goes last now, under Add directory. And it was a `settings-hint`: dim grey body text. So "two roots would both be called uploads" read as a footnote about the section rather than as the reason nothing happened. A refusal is styled as one and carries `role="alert"`, so it is announced rather than only drawn; a success stays quiet. Measured in a real Electron window rather than assumed — the failing add driven through the typed-path form, then the message's box compared against the table's and the button's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): New folder did nothing — prompt() throws in the desktop clientChristophe Besson2026-09-0712-17/+90
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The same `static/` tree is the web page and the application, and Electron does not implement `window.prompt`: Chromium leaves it to the embedder and Electron declines. It does not return null — it **throws**. The call sat above its own try, so clicking produced no folder, no error and nothing on screen to react to. A dead button, which is exactly how it was reported. Measured against this repo's own Electron 44 rather than assumed, because the first two diagnoses this session were reasoned and wrong: prompt('name?') -> Error: prompt() is not supported. confirm('sure?') -> opens a real modal alert('hi') -> opens a real modal So `confirm` and `alert` stay — a dozen call sites depend on them — and only `prompt` is banned. `test_no_prompt_in_the_spa.py` holds the whole tree to it, with the near-misses it must not flag (`mkdir_prompt`, `promptForName`). The name now comes from a field in the toolbar, which works in both clients and can show the node's refusal beside the input instead of after a dialog has closed. Navigating away drops a half-typed name: it would otherwise create the folder somewhere the person is no longer looking. The rest of the chain was verified end to end and was sound: `dir_create` {dir,name} → the node's handler → `dir_create_ack`, and `list_dirs` walks the filesystem rather than the index, so a folder with nothing in it appears on the very fetch that follows. The field itself was then driven inside a real Electron window — typing, Enter, the click, and the icon rendering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): bring back New folder, icon-only — and close the hole it openedChristophe Besson2026-09-062-1/+22
| | | | | | | | | | | | | | | | | | | | | | | | | The control was hidden and its `canCreateDir` left computed and unused. It is back in the Files toolbar as an icon: the toolbar already carries one labelled primary action, and a second beside it competes for the width the breadcrumb trail needs. The name is in `title` *and* `aria-label` — a title is invisible to a screen reader on a button with no text, so an icon-only control without both is simply unnamed for anyone not reading with their eyes. Its gate changes. It required `isNodeAdmin`, which contradicted the node's own rule — "making a directory is not a privileged act; a member who can add a file can organise where it goes" — and hid the control from everyone who could have used it. It now follows the Upload button: a writable root, and not at the top of a group, where the level is the set of roots rather than a directory on anyone's disk. Restoring it surfaced a real gap. `_do_dir_create` never learned about RO/RW: `_do_file_upload` gained the `writable` check with the model and this one did not, so a member refused a file in a published library could still leave empty directories all through it, and could write to a drive mid-eject. Read-only has to mean read-only for every way of writing, not just for files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix: a root change reaches every client without a page reloadChristophe Besson2026-09-062-6/+25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The directory table travelled on `index_sync` alone — a *full* index, which the node only ever sends on request. Every ongoing change went out as an `index_delta`, which carried files and nothing else. So the message that says "something changed" was the one message that could not say a root had. The acks hid it: `root_add_ack` and friends broadcast the new table to whoever is connected, so the common cases looked right. What that could not cover was the operator's own client, where the ack landed and was then overwritten — the table calls `onRefreshIndex` after an add, that fetch returns the set from *before* the node's reload (fire-and-forget, because a rescan is minutes on a real library), and `applyIndex` writes it over what the ack had just delivered. The new directory appeared for one paint and vanished. Two halves. `index_delta` now carries the roots table, sealed with the rest and identical to `index_sync`'s — additive, so a 1.0 client sees a field it does not read. And the table no longer refreshes the index after a root change: the ack gives it the new set immediately, and the delta the node pushes when the scan finishes gives it again, along with the files. The test that pins it uses an *eject* as its case, because an eject changes no file at all — the entries freeze — so its delta is empty of additions, deletions and updates. Without the table it says literally nothing, which is how a library disappearing from under a group went unannounced to everyone but whoever pressed the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(node): `root list` printed "?" for every pathChristophe Besson2026-09-061-5/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | `RootSet.describe()` feeds two audiences that want opposite things. The index payload goes to every member and has always deliberately carried no paths — a member is told what exists and whether it is readable, not that the library sits under someone's home directory. The loopback API answers the operator themselves, over a channel that already requires their machine and the run token, and the path is exactly what they asked for. The `root list` CLI I added in Phase 1 read `path` from the member form, so it printed a placeholder for every directory. Nothing caught it: the CLI reads a dict, the API returns a dict, and neither end states which keys it owes. `describe(with_paths=True)` is the operator's view and `list_groups` is the only caller. The shared-directories table has the same hole over MNP — the roots there come from the index payload — so its Path column now appears only when a path is actually present, rather than rendering a column of blanks. The test asserts both halves, because they pull opposite ways: one that only checked the operator sees paths would be satisfied by leaking them to every member. It reads the indexer's source for the member side, and compares the CLI's key reads against what the payload offers for the other — checked to fail in each direction independently. 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-064-17/+37
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | There is no `uploads/` subdirectory any more, and the client names the folder rather than the root. It was the last of v5's quarantine — the per-user layer went on 2026-08-14 for the same reason — and it goes on the same grounds: a folder appearing beside the operator's library because somebody sent a file is the node deciding how their disk is arranged. Somebody dropping a file into the folder they are looking at expects it to be in that folder. **What made the quarantine worth having was never the subdirectory.** It is the filename allowlist, the size cap, the chunk ordering and the no-overwrite rule, and all four are untouched: an existing file is never replaced, the second sender of IMG_1234.jpg gets a free name, and the check still sits at the write. Letting the client choose the destination is safe for one reason and only one: it is resolved through `RootSet.resolve()`, which refuses `..`, absolute segments and anything whose resolved form escapes its root, symlinks included. A member answers "which of this group's folders", never "which path on the operator's disk" — and the test that used to assert the node chose now asserts that, with six shapes of escape. `direct` goes with it. Its only job was to say "no subdirectory for this root", which is now every root, and a config flag that does nothing is worse than none. Chat's attachment folder finally does something: the directory the operator picks in the Chat settings pane is where attachments are written, falling back to the first writable root while they have not chosen one, or if the one they chose has since been made read-only or ejected — a stale choice should not become a refusal at send time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): the folder picker had no background, and its field no layoutChristophe Besson2026-09-0612-29/+123
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects from one screenshot, and a test for the class behind the first. **The modal was transparent.** `.ftp-panel` asked for `var(--bg-panel, var(--bg))` and this stylesheet defines neither — the palette is `--bg-base`, `--bg-surface`, `--bg-raised`. An unknown custom property makes the whole declaration invalid while the rule around it still applies, so the panel simply had no background and the page showed straight through it. Three more of mine were the same: `--bg-hover`, `--bg-input`, `--danger`. **The field had no layout.** It was built on `.settings-row`, which is `display:flex; justify-content:space-between` — so a label, a hint and a value inside one end up spread across a single line in source order, which is how it read as three unrelated fragments per app. It is its own block now, and the chosen folders are a table borrowing `.shared-dirs-tbl`: these are lists, and a wrapped run of chips gives nothing to scan and nowhere to put a per-row remove. The operator is looking at two lists of directories on one page and they should read alike. `test_css_variables.py` is the general form. CSS fails silently and generously here, and nothing checked. It found four more that predate this branch: the unread-count badge (`--danger`) had white text on nothing, a transfer link had no colour, a notification card had no rounding and no unread marker. Fixed, and `--warn` and `--accent-bg` are promoted from literal fallbacks to real palette entries at exactly their current light values. Two of its own regexes were wrong before they were right — a scoped definition written inline, and one preceded by a comment, were both reported as undefined. A third check comparing the two palettes fired on `--border-focus`, a focus ring the themes share deliberately; a heuristic that has to be explained away on its first run is worse than no test, so it is gone rather than exempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): degrade against a node still speaking MNP 1.0Christophe Besson2026-09-0614-4/+108
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The SPA is served by the hub, so deploying the hub puts this client in front of every node — including the ones not updated yet. That window is the normal state for as long as an operator takes, and for a node someone else runs it may be indefinite. Three controls were broken across it, and the failure mode is quiet: an unknown message type is logged by the node and never answered, so the click produces a thirty-second wait ending in a timeout with nothing on screen to say the node simply cannot do this. Files' Upload button read `root.writable`, which a 1.0 node does not send — it says `upload`, the same answer under the older name. The button disappeared on every un-upgraded node. It reads both now, and still respects an explicit `writable: false` rather than falling through to the legacy flag. The per-app folder pickers spoke `app_directories`. Videos, Music and Photos each had their own message before that and those still work, so the page chooses by version: an operator on an older node keeps the ability they had. `video_root` and `audio_root` hold one folder, so several are refused with a reason rather than stored as the first and silently truncated. Root management — writable, removable, eject, plug — has no older equivalent to route to, so the table goes read-only with a line saying why and pointing at the `meshbay-node root` commands. Chat's two settings are new with nothing before them and are hidden the same way. None of this was inferred from a payload's shape: `_checkNodeVersion` already parsed the node's version and threw it away, and it is kept now. Coupling a capability to whether some field happens to be present is how a flag flips because an unrelated payload changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): a successful root op must never blank the operator's tableChristophe Besson2026-09-061-1/+5
| | | | | | | | | | | | | | | | | | | | | | | `ops.update_root` and `remove_root` returned `[]` when the group context had no live RootSet, and `group-page.js` accepted it: `if (msg.roots)` is true for an empty array. An op that succeeded would have emptied the shared-directories table, and "the node says this group has no directories" is not something the client can tell from "the node could not say". Both ends now refuse it — the node builds from config rather than answering empty, and the client requires a non-empty array. Found while adding `test_spa_imports.py`, which is the other half of this: it resolves every named import across the SPA against what the target actually exports. That failure has a shape nothing else here catches — no build step to fail, so the browser resolves the graph at load, finds a missing binding, and the page renders blank or the component just does not appear. `node --check` parses one file at a time and the source-reading guards look inside a file rather than between two. The settings split moved two shared components into a new module and rewired eight files to import them, which is exactly the change where a rename lands in one file and not the other. 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-0626-635/+1329
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-0619-204/+521
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-0620-297/+522
| | | | | | | | | | | | 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>
* fix(node): inline @owner next to group name, fix buffering layout shiftChristophe Besson2026-09-062-13/+16
| | | | | | | | | Put the @owner handle on the same line as the group name (flex baseline) to save a row on mobile. Move the video buffering indicator inside the video-container as an absolute overlay so it no longer pushes the video down when it appears briefly during seeks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): add ".." parent row in file explorer, hide mkdir buttonChristophe Besson2026-09-061-5/+14
| | | | | | | | | Show a ".." row at the top of subdirectory listings to navigate back to the parent. No path traversal risk — currentPath is internal state built from the node's own index. Hide the mkdir button (not useful yet); update the upload-controls test marker accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): always-on file selection with select-all checkboxChristophe Besson2026-09-061-27/+30
| | | | | | | | | | Remove the Select/Done toggle — checkboxes and action buttons are always visible. A select-all checkbox in the header row follows the standard pattern: check selects all visible items, uncheck clears the entire selection, indeterminate when partially selected. Navigation and file preview are unaffected (only the checkbox selects). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: bump all packages to 0.11.00.11Christophe Besson2026-09-051-1/+1
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(client): hide the tray button on the narrow layoutChristophe Besson2026-09-051-0/+5
| | | | | | | | | | | | Same 768px breakpoint the hamburger appears at, where the nav is tightest and the button has least to offer. Belt and braces rather than the only guard: `capabilities.tray` is false without the Electron bridge, so no browser has ever rendered this. What this covers is the app's own window dragged narrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(client): tray menu starts and stops the node, and a clearer iconChristophe Besson2026-09-0512-4/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The first icon was an arrow dropping into a receptacle, which is the download glyph -- a vertical stem above a container reads that way whatever the context. Replaced with a window folding a chevron into itself: no stem, and the frame says which object is being minimised. Applied to both the nav button and the panel indicator, which carried the same wrong shape. The menu now offers Start or Stop for the node daemon, chosen from its actual state and shown only when there is a daemon to act on: `supported && installed`, so a machine with no node installed gets no entry rather than a control that fails when used. `restart` is the start verb -- systemd's restart starts a stopped unit, and there is no separate one to call. The three service handlers become named functions so the tray drives exactly what the Node page drives, instead of a second copy of the systemctl and Task Scheduler branches. A read of the state that throws is treated as no control at all. The menu is rebuilt on a 5s timer while an indicator exists, and again straight after an action. libappindicator has no "menu is about to open" event, so a menu built once would show a stale Start/Stop for the life of the process; `systemctl --user show` costs a few milliseconds. Locales: tray.start_node / tray.stop_node in all ten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(client): minimise to a system tray indicator (GNOME)Christophe Besson2026-09-0514-2/+82
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A dedicated monochrome button in the nav, immediately left of the notification bell, hides the window to a tray indicator. Linux only for now; Windows is being done on that OS, and the capability is declared per platform so the button never appears where the desktop shows no indicator -- there it would hide the window for good. Hides, never closes: `window-all-closed` quits the app, so closing here would make "minimise" mean "exit" and drop the session, the transfers and the node connection. `second-instance` now calls the same restore path, since focusing a hidden window does nothing visible. The context menu is not decoration. Under libappindicator -- how GNOME shows a tray at all, via the AppIndicator extension -- `tray.on('click')` never fires; the indicator only opens its menu. A tray whose sole affordance was a click would be inert on the one desktop this targets. The click handler is kept for desktops that do send it. Menu labels come from the renderer with the IPC call: the locale files are the interface's, the main process has no i18n, and a second string table is how two of them start disagreeing. English fallbacks if none arrive. The icon lives in src/, not build/: package.json `files` packages only `src/**` and `ui/**`, so an icon under build/ is present in a dev run and missing from every installed one. Monochrome, stroked, matching the nav glyph. Verified on this host: Ubuntu GNOME with ubuntu-appindicators@ubuntu.com and libayatana-appindicator3 present, so the indicator has somewhere to appear. Not yet run end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(win): graceful shutdown, one startup-mode control, and a stray-\r bugChristophe Besson2026-09-0511-84/+127
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(client): a Node-page toggle to switch into/out of service modeChristophe Besson2026-09-0512-0/+84
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The installer's own mode question is effectively one-shot: customInstall skips it entirely once the firewall rules already exist, for any reason -- and per-user mode sets those up on its own, with no Scheduled Task involved. So declining once (or the rules existing from something unrelated, as happened on a dev machine this session) was a dead end: no reinstall, repair, or uninstall/reinstall cycle could ever bring the question back, since uninstall defaults to leaving both alone. Add the other door in (and out): a checkbox on the Node page, next to the existing per-user autostart toggle, wired main.js -> preload.js -> platform.js -> node-page.js. It runs packaging/win/service-mode.ps1 -- the exact script installer.nsh already runs -- via one Start-Process -Verb RunAs elevation, so the two paths can never disagree about what service mode means. The elevation helper writes a tiny param()-based .ps1 to %TEMP% so the target script path and its arguments bind through real PowerShell parameters instead of nested string-quoting. Also fixes a real pre-existing gap found while checking this: 8 of the 10 locale catalogues (all but en/fr) were missing the autostart/service-mode keys added in an earlier commit this session (b782886) -- test_locales.py's key-set-parity check uses a for-loop with an inline assert, so it stopped at the first mismatch (fr) and never actually reached the other eight. Backfilled all five keys (three pre-existing, two new) in de/es/it/ja/nl/pl/pt-BR/zh-CN. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: opt-in Windows service mode (boot-time, one elevation) + v1.0.0Christophe Besson2026-09-043-0/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* fix(client): Stop/Restart buttons and an autostart toggle on the Node pageChristophe Besson2026-09-044-0/+37
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Root cause of both reports: node:service-status's `installed` field was winAutostartInstalled() -- whether the Startup-folder launcher exists -- not whether the daemon can be managed at all. The Node page gates Stop/Restart on `installed`, so with no autostart configured (the default -- nothing installs it automatically) those buttons silently never rendered, leaving only Start. A perfectly manageable daemon looked unmanageable because a different, unrelated setting was off. installed now reflects the actual daemon binary (findNodeBinary()), same definition node:installed already used; autostart moves to its own field carrying what installed used to mean. That field also fixes the other half: there was no way to turn autostart on except the CLI. NodeServicePanel now shows a toggle-switch next to Start/Stop/Restart, wired through a new platform.node.autostart (install/remove) that mirrors the existing service.* pattern -- the preload/main.js bridge (W3) was already there, just never called from the interface. English and French strings; other locales fall back to English per the project's own stated policy (test_locales.py's own docstring). Verified: node --check on every edited file; hub/node suites green (863 pass, 0 fail — pre-existing test_locales.py encoding failures on this Windows checkout are unrelated, reproduced identically on the clean tree). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: drop the retired Mozilla STUN server from the defaultsChristophe Besson2026-09-042-2/+3
| | | | | | | | | | | 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>
* fix: tolerate clock skew when verifying JWTs (leeway 60s)Christophe Besson2026-09-041-1/+3
| | | | | | | | | | | | | | | A client whose clock is a little fast could not connect at all: the MNP handshake verified the hub-issued token with no leeway, so a token whose `iat` was a few seconds ahead of the node's clock failed with "the token is not yet valid (iat)". Seen against a freshly-resumed VM guest. `meshbay_common.handshake.JWT_LEEWAY_SECONDS = 60` is the shared value; applied to the handshake, the node's own hub-token decode, revocation-token verification, and the hub's access-token decode. 60s absorbs NTP-level skew without meaningfully widening the window on a stolen token (they already carry a jti and an exp). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): resolve STUN hostnames in the main processChristophe Besson2026-09-041-8/+31
| | | | | | | | | | | | | | | | | | | | | | | Chromium's P2P socket manager failed every STUN hostname with ERR_NAME_NOT_RESOLVED in a restricted-resolver environment (a Windows KVM guest), even though its own general network stack, the OS resolver and Node's resolver all resolved the same names -- and mapping the names to IPs with --host-resolver-rules changed nothing, so it is not ordinary resolution. WebRTC was left with no server-reflexive candidate. The desktop client now resolves the STUN hostnames in the main process (`ice:resolve-stun`, Node's dns.resolve4) and hands `transport.js` the IP form; a name that will not resolve (the decommissioned Mozilla host) is dropped. In a browser there is no `meshbay` bridge and the hostnames are used unchanged -- a browser resolves them fine, so that path is untouched. Falls back to the hostname form if the bridge call throws. Also, scoped to win32: disable WebRtcHideLocalIpsWithMdns, so the client publishes its real local IP instead of a `.local` name the node's ICE stack cannot resolve across the KVM bridge. Changes nothing on Linux/macOS. Test-env workaround, revisit before release. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat!: MNP 1.0 — seal index and handshake_ack under the group keyChristophe Besson2026-09-033-20/+255
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
* refactor(hub): drop the base64 chunk fallback and the dead HTTP file clientChristophe Besson2026-09-032-78/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | With MNP 0.15 no node can emit a base64 `file_chunk`, so the browser's fallback for that shape is unreachable. Three things go with it: - `file-utils.js` kept a third branch below the fallback that base64-decoded `chunkMsg.ct_b64 || chunkMsg.data_b64` when neither was present, i.e. decoded `undefined` and wrote the result into the file the user was saving. A chunk we cannot decrypt now stops the download with an error naming the file and suggesting the node is older than the page. Deliberately not in `_isRetryableTransportError`: this is a version mismatch, not a bad moment on the link. - `crypto.js` `decryptChunk` (base64) was the real path until Phase 9.15 and has had no caller since. - `crypto.js` `decryptFile` was never called in any commit. It fetched `${nodeUrl}/file/${id}/${chunk}?token=` in a loop — the node's unauthenticated HTTP file API, which is finding C1 and was deleted in Phase 11.5. A client for an endpoint that no longer exists, kept alive by being exported. `decryptChunkBin` — every file download and every video segment — is untouched. `packages/meshbay-client/ui/` was resynchronised with `npm run sync-ui`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* fix(hub): route the chat ack to the request that asked for itChristophe Besson2026-09-031-0/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Typing a message froze the Chat tab: the composer stopped taking clicks and keystrokes, the message never appeared, and it was there all along on the next visit to the tab. The node answers a chat message with a bare {"type": "ack"} -- no request id, no type of its own -- so _dispatch had nothing to match it on and left it to the arrival-order guess at the end of the function. That guess is wrong the moment anything else this browser asked for is still waiting: the ack went to *that* request, and the chat send waited out _sendAndWait's own 30s timeout. Since the composer is disabled while a send is in flight, that reads as a frozen tab; the node had stored the message and answered, into somebody else's promise. An outstanding request is the ordinary case, not a rare one. The node refuses an unknown file_id with a bare `error`, which names no request either and so reaches none, leaving the Videos tab's media_meta_req in _pending for the full 30s. That is the one that was live when this was found. - `ack` is now matched by request type: chat_msg, or the keypair-bundle store and delete, which name themselves in `detail`. A node naming neither still has its reply placed rather than dropped. Every line of chat-app.js is correct and every routed message in transport.js is routed correctly -- the defect is in the seam, so tests/harness/ chat_send_probe.py drives the two together: the real ChatPanel over the real MeshBayTransport, with only the DataChannel replaced by a stand-in answering what the node answers. test_chat_send.py asserts against it, and with the fix reverted all three of its tests fail on the three visible halves of the defect -- the composer still disabled, the message absent, and the ack resolving the unrelated request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFF4BL8VSKrghkSLzCrTVs
* fix(hub): stop the maintenance loop racing the tests, and pin _ASSETSChristophe Besson2026-09-021-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-0216-34/+158
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-023-6/+217
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* fix(hub): dismissing a notification deletes itChristophe Besson2026-09-022-15/+54
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 dismissed notification stays dismissed across a restartChristophe Besson2026-09-021-2/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking a notification navigated to the group and the entry disappeared — the intended behaviour — and it was back on the next launch. Neither half was wrong on its own, which is why it survived. `markRead` drops the entry locally *and* marks it read on the hub, deliberately: "Reading it is the point of clicking it: it goes, here and in the count, rather than sitting there greyed out." The hub honoured that and persisted it. But the startup fetch asked for `/v1/notifications?limit=20` with no filter, and the endpoint returns read and unread alike, so every dismissed notification came straight back. It asks for `unread_only=true` now — a parameter the endpoint already had and already tested. The feed still carried the fossil of the older intent: `class="notif-item ${n.read ? '' : 'notif-unread'}"`, styling for a read entry rendered greyed out, from before clicking meant dismissing. Nothing read reaches the feed any more, so that branch was dead code describing behaviour the application had abandoned — and noticing it is what made the two halves' disagreement visible. Removed. `unread_count` is computed server-side over the whole table and is unaffected by the filter, so the bell is unchanged. test_notification_dismissal.py holds both halves: the API round trip that is the reported bug (list, read, list again), its mirror showing the unfiltered endpoint still returns it — so the fix cannot read as a coincidence — and a static check that the SPA asks for the filter, which is the only one of the three that catches the defect that actually happened. Verified by dropping the parameter again: that one fails, the API tests do not. Read notifications now accumulate unread in the table rather than being deleted. Purge removes them; the volume is small. Making dismissal a delete would suit `purge_notifications`' own docstring — "these are signals, not a record" — but it would leave `/read` a misnomer and `read-all` inconsistent, so it is a separate decision. 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-023-2/+38
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-023-2/+54
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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): the Node page takes the width of a settings pageChristophe Besson2026-09-021-1/+7
| | | | | | | | | | | | | | | | | | | | | | | | `.node-page` carried `max-width: 700px` of its own while Settings, Profile and the create-group wizard take `.main`'s. The audit tab is a six-column table — timestamp, event, user, IP, group, detail — with every fixed-shape column set `white-space: nowrap` so an IP is never clipped, so at 700px it scrolled sideways inside `.node-table-scroll` with a couple of hundred pixels of `.main` empty beside it. Measured at 1024: 596px of table box where the page had 856px to give. The rule goes; the class stays as the anchor for the assertions. `.node-group` and `.settings-section` are already the same rule twice over (same background, border, radius, padding), so the two pages now line up card for card. test_node_page_width_measured.py: the Node page and a Settings page are the same width at every width from 320 up, their cards the same rectangle, the audit table no longer wider than its scroller at 1024, and — the narrow case being the design rather than a regression — the table still scrolling inside its own box on a phone without pushing the document sideways. 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-10/+26
| | | | | | | | | | | | | | | | | | | | | | | | | | | 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-022-9/+88
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-0212-31/+276
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a different season of the same show moved everything under the synopsis, which is where the season control and the episode list are, so the thing just clicked was no longer under the pointer. - The synopsis is exactly three lines for a multi-season show, with a "read more" link floated into the third line box (-webkit-line-clamp only ever puts its ellipsis at the end of the last line and leaves no room after it). Clamped from above and pinned from below to the same number: a constant, not a range — a season summary runs two lines and the next one twelve, and a band still reads as a jump. Whether three lines is all of it depends on the modal's width, so it is measured in the browser and re-measured on a resize. - The cast is clamped to two lines. - SeasonMenu replaces SeasonTabs: the tab row scrolled sideways once a show had more seasons than fit, which is close to unusable on a phone. One trigger reading "Season 5 · 1997" and a menu of every season with its episode count, one row high whatever the season count. - media_meta_resp.director was filled from the credits crew's job == "Director", a movie shape. TMDB's aggregate tv_credits crew is routinely empty and never carries that job, so every show answered null and the modal dropped the line. It now comes from created_by on the show details. Cached show metadata keeps its null until TMDB_META_TTL_SECS expires or an operator re-matches. The facts line is joined rather than concatenated (a title with no rating used to open with " · ") and carries the show's own year next to the director; the selected season's air year moved onto the picker. test_video_detail_measured.py asserts rectangles through layout_probe.py, not declarations: the picker's offset inside its own modal body is the same pixel either way, the synopsis and cast heights, where the read-more link lands, and the open menu at 320 px. Each measured block sits in a whole-pixel-height container, or two identical layouts an eighth of a pixel apart round to tops one pixel apart. test_tmdb_show_director.py covers the credit. docs/mediacenter.md §10.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): fall back to the group's first app when the landing tab is absentChristophe Besson2026-09-021-1/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group could open on a tab that rendered nothing: no panel, no tab shown active, and nothing on screen to explain it. The landing tab is chosen at mount from a preference -- default_tab for the group, else the account-wide one, else 'chat'. Which applications the group runs comes from the node, in the handshake ack, several awaits later. A preference is a preference, not a promise that the app exists here, so the two disagree in two ordinary cases: the group has Chat disabled while 'chat' is everyone's default, or the reader prefers an app this group does not run. `apps.map(a => tab === a.key && ...)` then matches nothing. The first app the group does offer answers both. Two more cases come free: a preference naming an app that no longer exists, and an operator disabling the app someone is currently looking at -- enabledApps changes live over apps_enabled, and being moved to a working tab beats staring at an empty panel. Settings is exempt: it is not an application, and the create-group wizard lands on it deliberately. `const apps` moves above the effect that reads it; a const further down would be in its temporal dead zone, which is the hook-ordering trap already recorded in CLAUDE.md. tests/harness/group_tab_probe.py renders the real GroupPage against a stub node answering a chosen enabled_apps and reads the tab bar back, over five cases. With the fix reverted the three fallback cases report no active tab at all and four of the six tests fail; the two that pass either way are the ones that must not change -- a group running everything, and a preference the group does honour (Videos stays selected, so the fallback has not become "always the first app"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
* fix(hub): let the reader scroll up in the chat againChristophe Besson2026-09-011-18/+81
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The chat could not be read back: any wheel gesture was undone in the frame it happened in, and the "jump to latest" button never appeared. None of the pins in ChatPanel are at fault -- every one of them is guarded by "only if the reader is at the bottom". The reader never got to stop being at the bottom. fit() set the panel's height, read documentElement.scrollHeight back and subtracted the overflow, so the document alternately did and did not overflow the window. The page scrollbar appeared and vanished with it and visualViewport fired resize at every pass -- the event fit() is bound to. It therefore re-entered itself for the life of the panel: measured at 240 firings in two seconds on a page nobody was touching, against 2 for a bare document. Each pass ran fitAndPin, which re-pinned the list to the bottom before the scroll event that would have recorded the gesture was delivered a frame later, so atBottomRef never went false. - fit() learns the space below the panel once and remembers it on the element instead of re-deriving it by writing and measuring back. At the steady state it writes nothing, so it produces no resize. A real window resize or an orientation change forgets the learnt value and measures again (the page under the panel may have reflowed); visualViewport deliberately does not, since a phone fires it constantly. - The scroll-to-bottom is now scoped to *arrival*, which is all it was ever for: opening the group, or coming back to the Chat tab, including the thumbnails and link-preview cards that keep growing the list for a second afterwards. It ends when the reader takes hold of the scroll, and the ResizeObserver disconnects there. - That release is recorded from the gesture (wheel/touchmove/pointerdown/ keydown), not from the scroll event, which arrives too late to protect anything. Unchanged: landing on the newest message, following new messages while already at the bottom, the "load older" anchor and the unread marker. tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser and reads a conversation back; test_chat_scroll_up.py asserts against it. With the fix reverted, five of its six tests fail and the sixth -- landing on the newest message -- still passes, which is the property that must not have been traded away. A structural test cannot see any of this, which is why it is measured. test_layout_responsive.py pinned the listener's name and follows the rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL