aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
Commit message (Collapse)AuthorAgeFilesLines
...
* fix(hub): the mail server is not a relayChristophe Besson13 days2-10/+160
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit metered the paths that send mail. It was not enough, and saying it was would have been wrong: a 60-second cooldown per account still allows one stranger a minute — 1440 a day — and registration is open, so "per account" is a bound an attacker buys more of. And there was a third door nobody had counted. POST /v1/users/register an address nobody has verified PATCH /v1/users/me an address nobody has verified, signed in POST /v1/users/password/reset only the address already on file POST /v1/groups/{id}/invite-notify only a registered member's address The widest was the register *resend* branch: no token, no captcha, and the username and address are the caller's own from a moment ago — registering a victim's address once bought the right to mail them at the endpoint's rate limit for as long as the account stayed pending. So the bound moves into `mail.py`, where every message passes one function. `purpose` is keyword-required and checked against a closed list, so a helper that names anything else does not send and one that names nothing is a TypeError rather than an unrestricted send. Under it: - a bound per **recipient**, across every purpose, account and endpoint — what a person being mail-bombed actually experiences, and the only bound that describes it. Keyed on a hash, because this would otherwise be the one place in the hub holding plaintext addresses in memory (S2) - an instance-wide hourly ceiling, which cannot be bought with more accounts - a cooldown on the resend branch, a cooldown and a daily ceiling on the address change, and the IP-log entry that endpoint never wrote — alone among the ones that mail The ceiling on address changes counts IP-log rows, not EmailVerification: the handler deletes this account's unverified rows before writing a new one, so counting those counts one, always. Which is what the first version of it did. Refusals never carry the address: that line goes to the journal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix(hub): no mail from the event loop, and a ceiling on every path that sends itChristophe Besson13 days4-16/+119
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `users.py` and `admin.py` under the availability lens. `admin.py` needed nothing — its moderator/admin line is drawn explicitly, self-modification is refused, and every list it serves is bounded. `users.py` had four findings and one of them is the worst of this whole pass. AV9 `mail._send` is `smtplib` with a ten-second timeout, called straight from four async handlers. That wait is not one request's, it is the instance's: nothing else served, no node socket read, no WebRTC offer relayed, until the MTA answers. Reachable by any signed-in user at request rate through the endpoint below. It has no symptom a test catches — everything simply works slowly, for everyone, whenever the mail server is having a bad day. AV10 `PATCH /v1/users/me` is the third path that makes the hub send mail and the only one with neither a rate limit nor a captcha, while `register` and `password/reset-request` have both. The address is any string the caller types and the duplicate check only rejects one already held by an account here, so every address *not* registered on this hub was a valid target: a relay for verification codes with the hub's own reputation attached. A rate limit counting by IP bounds a caller and not an inbox, so the floor under it is a cooldown per account — the same for a reset request, whose cost also lands in a mailbox that is not the asker's. AV11 `default_tab:` accepted any suffix on a `{key:path}` route with an unbounded Text value and no cap on rows: one account could write without limit into a table shared with everyone. The suffix is a group id, which is what the SPA writes, so it is checked as one. A key over 64 characters was also a 500 rather than a 400 — the column is String(64), which PostgreSQL enforces and SQLite does not, so it would have appeared in production and in no test. AV12 `/v1/notifications` and `/v1/groups` had no upper bound on `limit` and no floor under `offset`, while every list in `admin.py` carries `le=200`. The group directory takes no authentication at all. Two shapes recur and are now named in §13.5b: a limit written on one of several equivalent paths, and a bound that counts the wrong thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix: bound what one member can cost the othersChristophe Besson13 days5-21/+203
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An availability review, prompted by the group claim above: a participant supplies input — who else bears the cost? Six answers where the cost fell on someone other than the sender, and none of them needs an attacker. AV3 `chat_notify` carried a `group_id` the hub believed, so any connected node could write a notification to every member of any group on the hub, carrying a display string of its choosing, with its account having no relation to that group. This is the group claim again, two hundred lines further down the same socket. Gated on what the node is registered for, and metered: the fan-out is one write per member. The budget expires by time rather than on disconnect, or reconnecting would refill it and a node token is good for an hour. AV4 A swarm source named its own `endpoint` as free text documented as "ip:port", so an account could publish a third party's address — H6's `peer_ip` defect, never applied here. Nothing dials a swarm source today, which is the only reason it was not already a reflection primitive. It is a transport and a port now, never a host, and the number of hashes one account may claim is bounded: rows were keyed (hash, account) with no cap at all. AV5 `handle_webrtc_answer` resolved any pending `peer_id` from any node's socket. The answer is the SDP a browser then connects to. That this had not happened rested on a uuid4 being unguessable. AV6 `relay_register` had no authentication of any kind: it compared `pk_relay` against the approved value, which is a *public* key, so anyone who could read it could rewrite where the hub tells nodes to send relayed traffic. The module docstring promised signed JWTs and `jwt` was imported and never used. AV7 The node held unlimited peer connections and kept one that never completed a handshake for the life of the daemon. H6 bounded what one unauthenticated peer costs; the hub's cap is three offers in flight per *account*, a limit on each caller and not on the machine, so an operator's exposure grew with the size of their groups. AV8 `invite-notify` put a request-supplied `group_name` into the subject of an email the hub sends under its own domain, to any account, with no rate limit. The name comes from the group row now. The tests are two accounts each, in one file that says why: a one-member test proves a one-member property, and every finding here needed a second person to exist at all. Each was checked against the unfixed code. Two did not survive that check and were rewritten — one re-enacted the disconnect path instead of running it (hence `forget_node`), the other called the reaper itself and would have passed with the call removed from `handle_offer`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix: an empty group claim is a claim on nothingChristophe Besson13 days3-22/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A node that hosts no groups sends no `group_ids` on its hub socket, and the hub resolved the claim with `set(claimed_groups or authorized)` — so "I host nothing" arrived as "I host every group this account belongs to", other members' included. Such a node can serve none of them: it holds no GEK, and its own handshake refuses them with "Group not hosted on this node". `/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is in-memory, so which node a client was sent to depended on who reconnected first after a hub restart. GroupPage took `nodes[0]` with no fallback. On 2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's unconfigured node won the race, and a group stopped opening for everyone in it with its only real host online throughout. Any member could take one of their groups down, by accident, by leaving an empty node running. Four changes, because no one of them is sufficient: - the hub never widens an absent claim, and `update_groups` goes through the same ceiling as registration — it assigned its list verbatim, so the bound that makes C2 hold at authentication was one message wide - the node states the empty set rather than omitting the field - the refusal carries `not_hosted`, so a client can tell "try the next node" from "you, here, must do something first" - GroupPage walks the list instead of indexing into it The three lines involved date from 13, 20 and 23 August and each is defensible alone. The defect is in the seam, which is where the last two also were: a falsy empty collection must never mean "unspecified". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix(packaging): three MSIX first-run regressions found by a real sideloadChristophe Besson13 days11-0/+38
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A second-machine sideload of the MSIX target surfaced three things the earlier verification round (which only proved the package installs and runs) had missed: 1. meshbay-node missing from PATH. installer.nsh's customInstall adds node-runtime\ to HKCU\Environment at install time -- an unelevated per-user write, never blocked by MSIX's no-elevation rule, only by the more basic fact that an AppX/MSIX install runs no custom code at all. packaging/win/ensure-node-path.ps1 (idempotent, no admin verb) plus main.js's winEnsureNodeOnPath() do it from the app itself instead, once per launch, shipped to Full and MSIX (not Light, nothing to add there). Verified live via the Node inspector protocol: the entry was in HKCU\Environment\Path after a launch, absent before. 2. A daemon that crashes on startup failed silently. spawnNodeDetached() used stdio: 'ignore', so a real crash reproduced live (a second instance colliding with the first on 127.0.0.1:18000) left waitForNode()'s generic 60s timeout as the only failure ever shown. spawnNodeDetachedWatched() pipes stdio and watches ~2.5s, rejecting immediately with the daemon's own stderr on an early exit; a survivor has its streams released and runs fully detached exactly as before. First version bounded the captured text by line count and a live test showed that cut the actual OSError line -- two uvicorn/asyncio tracebacks followed it in the real capture -- so it is bounded by characters instead. 3. No hint that a startup-mode choice exists. The install-time radio page was the only place this was ever offered, and nothing replaces it now that no install-time page can exist at all. SetupWelcome (the existing first-run banner) grew a conditional hint, shown only while a bundled node is present and neither autostart nor service mode is configured yet. Considered and rejected: linking straight to the Node page -- its route is gated on a linked hub node key, false on the exact fresh-install screen this hint targets, so the link would have been dead on arrival. New key setup.node_startup_hint, added to all ten locale catalogues. test_packaging_win.py gained six tests pinning all three (69 total). Full plan and verification detail: C:\Users\admin\devel\msix-installer.md section 13 (out of repo). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(hub): an administrator can erase an account that owns groupsChristophe Besson2026-09-1115-36/+124
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An administrator's deletion answered 409 for any account owning a group, so an erasure ordered by an authority had to wait on the person it was about. It now deletes the account's groups with it, then pushes a signed revocation for the account and for each group to every connected node: an access token already issued stays valid on a node until it expires, and the revocation is what makes the nodes refuse the account and close the groups' sessions now. The action is written to the IP log, and the confirmation dialog says the groups go too, in all ten catalogues. The owner's own deletion is unchanged: refused while they own groups, which they can hand over first (CGU 3.4, privacy statement). Deleting a group had three partial cascades. The owner's route left email_verifications behind, and the cleanup of unhosted groups left notifications, invitations and reports - each an IntegrityError on PostgreSQL, invisible on SQLite, which does not enforce foreign keys by default. db/purge.py is now the one implementation: it finds every table referencing groups.id from the schema, deletes the group's rows and detaches content reports, which are evidence and outlive the group. test_group_purge.py turns foreign-key enforcement on for its connection, seeds every referencing table, and fails without the fix on all three routes. MESHBAY_DESIGN.md 7.7 states the rule, and now lists the device keys and swarm sources that e3c68b3 erases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* feat: gate the create-group wizard on whether a node is bundledChristophe Besson2026-09-113-11/+44
| | | | | | | | | | | | | | | | | | | | MeshBay Light has no bundled meshbay-node.exe, so the create-group wizard (which assumes it can start a local node) needs its own signal, not just platform.node.available. main.js exposes it over IPC (node:bundled) by checking the packaged resources directory rather than trusting a build-time constant; preload.js and platform.js carry it through the usual contextBridge/wrapper path. winCanElevateServiceMode() replaces the two prior 'app.isPackaged' checks for whether the app can offer service-mode elevation -- Light is packaged but has no service-mode.ps1 to elevate into, so packaged alone was already the wrong test even before this target existed. create-group-page.js gates the wizard step that starts a node on the new capability instead of hiding the whole feature; node-page.js's comment fix is unrelated cosmetic drift caught in the same pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(hub): the asset fingerprint covers every file under static/Christophe Besson2026-09-111-38/+14
| | | | | | | | | | | | | | | | | | | Everything under static/ is served at /a/<hash>/ with a year's `immutable`, but the hash was computed from a hand-kept list of 43 top-level modules. The ten catalogues and vendor/ were not on it, nor was anything the guarding test could see: it globbed *.js at the top level only. A change confined to the catalogues therefore kept the hash, and a phone went on showing a heading that had been rewritten and deployed - pull-to-refresh fetched the no-store shell, which was current, and never refetched en.js at a URL that had not moved. The fingerprint now hashes every file under static/, path and content, so a change, a rename or a new file moves the version with nothing to register. _ASSETS is gone, and CLAUDE.md, MESHBAY_DESIGN.md 9.4 step 6, assets/brand/README.md and docs/playlists.md no longer ask for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* feat(hub): legal information link at the foot of the sidebarChristophe Besson2026-09-1113-3/+47
| | | | | | | | | | | | | | | | | | | | | | A small link to the hub's legal pages, pinned to the bottom of the sidebar once signed in. It opens in a new tab: the desktop application refuses to navigate away from its interface and hands a new window to the system browser, and in a browser it keeps the session on screen. The address comes from hubBase(), so it is the legal pages of the hub in use. The sidebar now sticks under the navigation bar at the window's height; otherwise, on a long file list, the link would sit at the bottom of the page. The music bar, pinned to the bottom of the window as well, publishes its height through useStickyBand as --music-bar-h and the sidebar stops above it. On a phone the slide-out panel does the same. test_sidebar_legal_measured.py measures the real stylesheet: the link at the bottom of the window on a 3000px page, and above the music bar, at phone and desktop widths. Checked signed in on a local hub in Chrome and Firefox 155. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* feat(hub): say what MeshBay is on the sign-in pageChristophe Besson2026-09-1112-2/+459
| | | | | | | | | | | | | | | | | | | | | | | | The browser sign-in page now carries the project's pitch beside the form: what MeshBay is, the applications, what it is for, and what meshbay.org does and never sees, with links to the downloads and the legal pages. Text on the left and the form on the right on a desktop; one column, form first, below 1000px. Not shown in the desktop application, whose user has already downloaded it. All ten catalogues carry the text. Every claim is held to MESHBAY_DESIGN.md 2.3: the page says content never reaches the hub, not that the hub can read nothing (T3), and "end-to-end" means device to node. Signed out there is no sidebar, so the 960px main column sat at the left of the window and the sign-in, register and reset forms were centred in it (x=290 in 1440). `.page-center` pages now lift that cap. test_welcome_layout_measured.py measures the real stylesheet in Chrome: no horizontal overflow from 320 to 1440px, form first on narrow screens, form right of the text on desktops, pair centred in the window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* fix(hub): account deletion left device keys and swarm sources behindChristophe Besson2026-09-111-2/+11
| | | | | | | | | | | | | | | | | erase_account cleared memberships, notifications, tokens and node registrations, but not user_devices or swarm_sources. A device key left on the tombstone still belonged to it, so an account created later from the same desktop installation - which keeps its private half - was refused that device with a 409 that only reached the console. swarm_sources is keyed by the user id despite its column name and carries the node's ip:port. Both are now erased, which is what the privacy statement promises: every account row goes except the one-year IP log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* fix(node): the handshake ack dropped one app's directoriesChristophe Besson2026-09-101-12/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The ack was assembled from its own tuple of application names, a copy of the daemon's `APP_DIR_KEYS`, and the two had drifted: the copy was missing `helloworld`. So the reference application — the one that exists to prove a new application needs no special-casing — was the single application whose configured folders never reached a client, which made the plugin claim false exactly where it is demonstrated. Fixed by removing the copy rather than syncing it. The ack now emits whatever `<app>_directories` the group context carries, and `_app_directories_ctx` is the only thing that puts one there, so the two cannot disagree again. The transport names an application in one place, `ALLOWED_APPS`, which is enforcement rather than a directory list. The client had the same fault one layer up: `group-page.js` read three names by hand from the ack while the live-update path beside it was already generic. It derives the map from the ack's own keys now, so the fix reaches the settings pane instead of stopping at the wire. A first attempt moved the list to `roster.py`, where directory *storage* lives, and `test_helloworld_proves_the_plugin_claim.py` refused it: the roster, the ops, the config and the root set must name no application at all. That test is the architecture's own guard and it was right — the list belongs on the daemon, which is what wires a group's context, and everything downstream is derived from it. Two new tests, both verified to fail against the previous shape: the ack carries an application the node names nowhere else, and the ack keeps no list of its own. `test_the_lists_are_read_under_one_name_each` now asserts the shell names no application rather than that it names exactly three. Two stale comments went with it — the ack's, which described scalars removed in 07ff8b4, and the client's, which said those scalars still rode the wire for MNP 1.0 peers that can no longer connect. Full suite: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* refactor(mnp)!: one operation for an app's folders, not one per appChristophe Besson2026-09-103-40/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `video_root`, `audio_root` and `photo_roots` are gone — the messages, the signed operations, the handlers, the `ops` wrappers, the three scalars on the handshake ack, and the client's handlers for their acks. `app_directories` does the same thing for every application, keyed by the app's own registry name, and it is what the SPA has been sending. The three were the same instruction three times, differing only in the key they wrote and whether they carried a string or a list. That shape is what made adding an application mean adding a message type, an op, a handler and a widget; it also meant three validation paths, and the older ones validated nothing — a typo was stored and then quietly matched no entry, an app showing an empty tab with no way to tell "misconfigured" from "no files yet". **What stays, and why.** `Roster.LEGACY_DIR_KEYS` still reads `video_root` and friends out of `group_settings`: that is a key on an operator's disk, not on the wire, and a node upgraded into this must find its own configuration. The Search page still reads its own older cache keys, for the same reason — the cache outlives a deploy. `CTX_ALIASES` keeps only `chat`, which is the one app whose second name something still reads. The two per-app policy test files go with the messages. What only they held — the real challenge/response path from message to database, which no other test exercises — is retargeted at `app_directories` in `test_app_directories_signed.py`, and the handler's own refusals (unknown app, malformed `directories`, nobody to authorize it) join `test_app_directories.py`. Node and common suites 1368 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* docs(code): drop the references to a plan file that no longer existsChristophe Besson2026-09-102-5/+6
| | | | | | | | | | | | | | | | Seven comments pointed at sections of `~/next/improve-downloads.md`, which is not in the tree and not anywhere a reader of this repository can follow. Each now states the thing it was citing: why a paused transfer holds nothing, why the lease is taken after the save target and not before, why a chunk request marks a lease alive, where the leaseless bound's number comes from. The leaseless comment also said "two files at a time" three paragraphs under `MAX_LEASELESS_IN_FLIGHT = 12`, left behind when the bound was raised. A comment that contradicts the constant beside it is worse than no comment: one of them is wrong and the reader cannot tell which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* refactor(spa): stop asking a node what version it isChristophe Besson2026-09-1014-222/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | `MNP_MIN_SUPPORTED` is the version this build speaks, so `check_version` refuses everything below it at the handshake. Every capability the client was gating on the node's version is therefore true of every peer it can reach: * `supportsSealedUpload` — an upload is sealed or it is not sent; * `supportsAppOps` — one `app_directories` op, and no `setVideoRoot` / `setAudioRoot` / `setPhotoRoots` wrappers behind it; * `supportsTransferSlots` and `Lease._skip()` — a lease is always real, so there is no branch where a transfer runs without one; * `legacyNode`, the read-only shared-directories table, and the two hints telling an operator their node is too old to configure an app. The version the node declares is still recorded, for diagnostics. Nothing branches on it, and the comment says so, because a field kept "just in case" is how the branches came back last time. `test_mnp_1_0_node_compat.py` goes with them: it existed to hold the fallbacks in place, and holding a fallback that cannot execute is how a suite starts lying. The two locale strings for those hints are removed from all ten catalogues. Hub suite 872 passed (test_sticky_header deselected — failing before this). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* refactor(mnp)!: one answer to "may this member write", and it is the rootChristophe Besson2026-09-103-38/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | The group-wide `member_upload` switch is gone: the message, the signed operation, the field on the handshake ack, the `upload` alias on every root in the index payload, and the client's fallback path to it. Whether a member may write has been a property of each root for a while, and that is the model that survives: a single flag over the group cannot express "this library is published read-only and that folder is a drop box", which is the ordinary arrangement. What was left of the switch was a handler that logged a deprecation and acted on nothing, and a client that read `ack.member_upload` whenever the roots carried no `writable` — a second source for one question, with whichever the code consulted first deciding it. `roots.describe()` drops `upload` for the same reason: it was `writable` under an older name, and two names for one boolean is one too many. The paperclip now says "nowhere to write" rather than picking a root, in a group that has none writable. That is the honest answer; the fallback picked whatever came first and failed at send time. Node suite 1215 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* fix(hub): a pinned band's ring stops eating the form above itChristophe Besson2026-09-101-0/+32
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The tab bar paints an opaque ring of page colour around itself, `--band-margin` wide, so the gap it keeps in the flow is still there once it pins. A box-shadow spread goes out on all four sides, and above the tab bar there is only whatever the element before it happened to leave: the join-code form under a group's title leaves 12px, the ring is 16px, and the form came back with the bottom 4px of its field and its button painted over — page colour at z-index 30, against content that has none to answer with. Reported as "the form is slightly cut off", which is exactly what it looks like and says nothing about a stylesheet. The same 4px went off the bottom of the "could not reach this node" banner, the other thing that stands between a group's title and its tabs. The band reserves that room itself now. `* +`, so it is the gap between two elements rather than a margin the band always carries: `.search-bar` is a first child on the Search page, and a margin-top there would collapse through the page root and take the whole page down with it. Between siblings the two margins collapse to the larger of the pair, so everywhere that already leaves enough is untouched and only what was being painted over moves. Only the two bands that pin against the navigation bar, and that is the rule rather than an economy. Written for all six it fails 22 of the sticky-header cases: the gap *between* two bands is the upper one's `--band-margin` and nothing else — the number `--chrome-h` carries and the offset the lower band pins at — so a lower band's own margin-top wins the collapse wherever it is the bigger of the two and leaves the flow layout wider than the pinned one, at every phone width in every media view. A band under another band needs no room above it anyway: what is there is a band of higher z-index, which a ring cannot paint over. Measured against the shipped GroupPage in the state that was reported — a node answering `code_required` — in Chrome: 12px of clearance under a 16px ring before, 16 against 16 after. test_sticky_band_ring.py holds the two selector lists together out of the source rather than in a browser, because what a browser shows is the 4px at one width in one of the states that happen to put something above a band, while what has to hold is which bands are in which list. docs/apps.md sends the author of a new application to that section to make its toolbar pin; this is what says the toolbar they add does not get the gap, and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAJawZ25MZPBJ7TKgnVA1n
* feat(hub): pin the app controls while a library scrollsChristophe Besson2026-09-108-28/+440
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Files, Videos, Music and Photos are read by scrolling, and everything that steers that reading left with the first screenful. Three bands now pin under the navigation bar, in a group and on the Search page alike: the tab bar (the search field, on Search), the application's own toolbar, and the file table's column heads. The group's name and description still scroll — they say nothing a reader needs while walking a directory, and the height they would cost is height the list does not get. A band's offset is the heights of the bands above it, and those are not constants: the toolbar wraps to three rows on a phone, grows a field while a folder is being named, and loses its filter on Search. So each band measures itself and publishes `--chrome-h` / `--toolbar-h` (static/sticky.js) and the stylesheet does the arithmetic in calc(), rather than a number written down twice — the fault CLAUDE.md already records against this layout twice over. A band publishes height *plus its own bottom margin*, and paints that margin as a ring of page colour, so the pinned layout is pixel-identical to the flow layout and nothing shifts at the moment a band pins. Three overflow faults came out of it, all of the same class and all of them what "the header does not stay" actually meant on Android — a document wider than the screen leaves everything pinned attached to a viewport the reader can no longer see, the navigation bar included: - a directory's name cell was a bare <td>, so an unbreakable folder name (`Rage_Against_The_Machine_Discography_1992-2000_FLAC`) set the column's minimum: a 527px table in a 390px window - Search's group column did the same at 442px with an underscored group name. It also goes entirely below 768px, where there is no room for it and the breadcrumb already names the group - the shared-directories table has four columns of controls with a combined minimum near 440px, none of it compressible. On a phone the row stops being a row: the name and its eject/remove pair on one line, the two switches — each carrying the column head's own string as a label — on the next - and, found by measuring at 360px, the tab bar itself was 19px too wide `.file-table` moves to separated borders: a collapsed border belongs to the table rather than to the cell, so the column heads lost their rule the moment they pinned. Measured, not read. tests/harness/sticky_header_probe.py drives the shipped GroupPage and SearchPage against a stub node, walks to each application, scrolls to the end and reports every rectangle — 11 views x 4 widths x 2 engines. Its fixture says what real data says: the first version used `note-007.txt` and `un groupe`, which fit any screen, and found none of the above. A fixture narrower than real data tests the fixture. Also: `test_desktop_shell` no longer looks for the CSP after the first `-->`, which made it fail on correct markup as soon as a comment was added above it, and `search-page.js` joins test_hook_ordering's file list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN
* test(spa): drive the composer's recovery through the real reconnectChristophe Besson2026-09-091-0/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The reconnect scenario added with the fix proved the composer follows `_setDevicePk`, and it poked that method itself at both ends. That is a narrower claim than it reads as: it says nothing about whether a reconnect *reaches* it, and the harness's own `Host` stands in for group-page.js, so a green probe did not mean the page joins the two. Both halves are real now. The scenario calls `connect()` with the arguments `_reconnectLoop` calls it with; it stops at signaling, because there is no hub in the harness, and the identity has to be gone by then — connect() drops it before it touches the network. The restore is the shipped `_announceDevice`, answered by the stand-in node with a `device_hello_ack` as `_do_device_hello` answers it, and the key it settles on is the one the following send seals and signs with. Three assertions check the scenario went that way rather than through a variable set by the test. The seam the harness cannot drive gets its own check: the wiring exists, the prop is in `commonProps`, and the callback is set *before* `connect()` — after it, device_hello's answer is missed and the composer starts closed. That check first passed with the wiring deleted, on the strength of a comment naming the callback; it matches the assignment now. Two things the harness turned up. `do_POST` answered every path, so the offer connect() posts to the hub was swallowed as the measurement and put the machine's own SDP, public address included, into the probe's output — it answers `/log` and nothing else now. And a connect() that gives up before `await channelReady` left that promise rejected with nobody attached, so closing the peer connection printed "Uncaught (in promise) DataChannel closed" on every failed reconnect attempt — noise in exactly the log a freeze is read from. Checked against the unfixed source both ways: with the clear removed from connect() and the wiring removed from group-page.js, three cases fail; with them back, 15 pass. Hub and node suites 2266 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
* fix(spa): a reconnect must give the Chat composer backChristophe Besson2026-09-093-13/+126
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The Chat tab froze about every other day — the textbox stopped taking clicks — and it never recovered on its own: no timeout ends this one, only leaving the group or restarting the client. A console dump of a session it happened in ruled out everything it could and named nothing. What that dump established was almost entirely negative, and that was the useful part. No `Response timeout`, no `unsolicited`/`unrouted`/`with nothing waiting` — so the 2026-08-30 routing defect, which produces this exact symptom for thirty seconds, had not recurred. No `PC state: disconnected|failed`, no second ICE cycle, no `Reconnected after N attempt(s)` — so the connection was alive and untouched. The freeze was in the page, and no path that logs anything had run. The composer is `disabled=${sending || cannotSend}`, and `cannotSend` was `transport.connected && !transport.devicePk`, read off a **ref** during render. `devicePk` is settled inside connect(), so every reconnect clears it and settles it again; a ref changing re-renders nothing, and nothing else announced it. So the panel went disabled on whatever unrelated re-render came next — a message arriving — long after the identity was actually lost, and had no event that would open it again. group-page.js never touches `status` after 'connected', and `onReconnected` is claimed by video-player.js, so there was no second chance. It was silent as well as sticky. `_announceDevice` had three exits that wrote `devicePk` without a word: two early returns that left the *previous* connection's value standing, and a reply that is not `device_hello_ack` — an `error` reply does not throw, so the `.catch()` at the call site never saw it. Reproduced in chat_send_probe.py, which mounts the real ChatPanel over the real transport: with the old code, identity cleared leaves the composer open, an arriving message latches it shut, and restoring the identity does not reopen it. Every write to `devicePk` now goes through `_setDevicePk(pk, why)`, which logs, traces and calls `onDeviceIdentity`; group-page holds the answer as state and ChatPanel takes it as `deviceReady`. Defaulting that prop to `true` fails open — a wiring mistake here must not be able to leave anyone with a dead textbox. Two things found on the same path and fixed with it. `_send` throwing inside _sendAndWait's executor left the pending entry and its 30s timer behind, so a request that never reached the wire still logged a "Response timeout" half a minute later. And the instrumentation this was meant to be diagnosed with (3be8bd2) writes to localStorage behind ?trace=1, not to the console, so the dump could not have carried it: the two lines that decide the composer's state are now logged unconditionally, and MeshBayTrace gains `record` so the composer writes into the same timeline as the channel events. Hub suite 2264 passed, 4 skipped. chat_send_probe.py gains a `reconnect` scenario and test_chat_send.py four cases, each checked against the unfixed source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
* fix: the PDF preview needs object-src and frame-src, in both policiesChristophe Besson2026-09-091-4/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A PDF preview showed the "this browser will not display the PDF inline" fallback everywhere — in the desktop client since its first launch, and in the browser since the hub started sending a CSP on 2026-09-01. It read as a missing native feature because before that commit the hub sent no policy at all, so Chrome had once worked and the application never had. Two directives govern one feature. `files-app.js` decrypts the file in the page and hands it to `<object type="application/pdf">` from a Blob; Chromium loads that as plugin data (`object-src`, absent and therefore falling back to `default-src 'none'`) and then renders it in an internal frame (`frame-src`). Opening either alone changes nothing visible — the second refusal produces the same fallback. `'self'` covers neither: a same-origin `blob:` URL is not matched by it in either directive, measured in Chrome 152 against the deployed page and in Electron 44 against the client's own policy. `plugins` stays at its default `false`: the built-in viewer is not behind that flag on Electron 44, verified by rendering one. Widening `object-src` from `'none'` to `blob:` admits only what page script minted itself, at a type this code sets — PDFium parsing bytes that came from a node, which is what any browser does with the same file once downloaded. Tests: each policy is pinned to carry `blob:` in both directives (each fails if either token is removed), and the two policies are now held identical directive by directive apart from the two deliberate differences — the comment claiming they were the same had already drifted and nothing checked it. The CSP source parser in test_desktop_shell.py read `//` comment lines as directives, which is the "parse directives, not text" mistake this file already records; it skips them now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XauykfBvRrpy6RYbF6F7Wu
* fix(spa): say why a download cannot be pausedChristophe Besson2026-09-0912-0/+34
| | | | | | | | | | | | | | | | | | | | | | | | | | Reported from Chrome, with a screenshot: four downloads with no pause button and an upload beside them with one, and nothing anywhere saying why. The reason is real. Without a granted download folder the browser writes through the service worker — a download it already owns, which cannot be paused without stalling it somewhere we can neither see nor resume. An upload writes to the node, which keeps the position, so it is always pausable. But that was stated only in a Settings line nobody reads on the way to a download, and a gap where the row above has a button is not an explanation. So a download that cannot be paused now shows a dimmed pause icon where the button would be, carrying the reason and the remedy in its tooltip. Not a button: there is nothing to click, and a disabled one invites the click anyway. And only where the advice can be taken. Firefox and Safari have no folder to choose — the streamed path is the only target they have, which is what §6.5 of ~/next/improve-downloads.md costs out — so telling someone there to choose one would be advice they cannot follow. Nothing is drawn. Hub suite 866 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): a paused transfer is not a finished oneChristophe Besson2026-09-0911-2/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported while testing the flag day: pausing an upload put it under "Finished". "Finished" was defined by exclusion — everything that is not running, queued or preparing — so it swallowed `paused` the day pausing shipped. A transfer somebody stopped on purpose then sat beside the ones that are actually over, offering a resume button in the section of things that cannot be resumed, and dropped out of the badge, which announced less activity than there was. Paused is now its own group, in all ten catalogues, and counts as active: it is not over, the person means to come back to it. The three filters are lifted out of `app.js` and executed rather than described in the test, and one case asserts that every status lands in exactly one group — a state added later that falls into none is a transfer the panel simply does not show, which is how this one got in. The same report also said the three running downloads lost their pause buttons when the upload was paused. That part is **not** explained and **not** fixed: the store returns `pausable` true and status `running` for all three (new test), closing an upload lease pumps only the upload queue, and the button's condition is a pure function of those two. All three say the buttons should have stayed, so an observation is missing rather than a cause. Hub suite 864 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat: MNP 3.0 — a transfer needs a leaseChristophe Besson2026-09-093-5/+22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 4 of ~/next/improve-downloads.md, the flag day. Leases become compulsory and a 2.x peer is refused at the handshake. **The bound on leaseless reads (§3.4.1) did not exist, and it is what makes the rest mean anything.** Browsing a group is never subject to a transfer slot — that is an operator decision and a requirement: a member must be able to browse a group at capacity exactly as they browse an idle one. But "not leased" cannot mean "unbounded", or a client that simply omits `tr` transfers outside every cap and the caps are decoration. A session may now read two distinct files at once without a lease: one because a viewer looks at one file, two so that prefetching the next photo stays possible. A count of files and not a byte budget, because a RAW photo is 60-80 MB and is browsing while a 40 MB archive is a download, and no size threshold separates them. Thumbnails, posters and cover art never reach this check at all — they resolve out of the node's own cache. It is a fairness control among cooperating clients, in the company of `max_concurrent_streams`, and is not a defence against a member determined to saturate a node's disk. That member is a member, and the answer to them is `member revoke`. **MNP_VERSION and MNP_MIN_SUPPORTED both move to 3.0**, on both sides. The messages are additive; the requirement is not. An opt-in switch would leave a leaseless branch reachable on every node, which is finding C6's lesson — a transport that accepted a bare JWT — one feature later. **The desktop client now checks before it connects.** The SPA is served by the hub and picks up a new client on reload; the application ships its own interface, so an un-updated one would sign in, list groups, and fail every connection with `version_too_old` — a refusal in a protocol vocabulary with nothing anyone can act on. It asks `/v1/hub/version` for `client.minimum` and says so plainly instead. An unreachable hub is deliberately *not* "too old": a captive portal or a closed laptop must not make starting the application impossible. **Every package is aligned on 0.13.0.** `meshbay-client/package.json` had drifted to 1.0.0 while the Python packages were on 0.12.0 — invisible until something compared those numbers, and then load-bearing: an installed client announcing 1.0.0 sorts above a 0.13.0 minimum and walks through the gate meant to stop it. That is stated in the code rather than left to be rediscovered; it is acceptable exactly once, because the operator is updating every client, node and hub by hand for this flag day. A new test fails if two packages ever disagree again, and another fails if the hub would refuse the client the tree builds. Node suite 1209 passed, hub suite 861 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): wake the download worker before handing it a streamChristophe Besson2026-09-092-1/+72
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported from Chrome: a download started while an upload was running took thirty seconds to begin, every time. The console named it exactly — /_mbdl/mtty5btz-sbmgdegx 404 () [MeshBay] the worker did not answer the download within 15s (attempt 1) A 404 from the hub means the request reached the *network*: the worker looked, found no entry for that id and let it through. So the worker was alive and controlling the page, and the message handing it the stream had simply never been processed. `pending` lives in the worker's memory, and a worker with nothing to do is terminated within tens of seconds. A WebRTC upload gives it no events at all, so minutes of uploading leave it dead; the stream posted to it is lost, silently, and the iframe then wakes it with nothing to find. `mbdl-ping` already existed for this exact reason -- sent every ten seconds *while* writing, because a streaming response does not count as activity. Nothing sent one before *starting*. So a download now wakes the worker and waits for the pong, and `sw.js` answers `mbdl-ready` once it has actually stored the entry, which the page waits for before navigating: confirmed rather than assumed. A worker that predates the ack sends nothing and the page navigates anyway, which is what it did before. This cause was measured and wrongly dismissed hours earlier, with an idle probe that made the worker work between its own attempts -- it never actually slept. A measurement that does not reproduce the conditions refutes nothing. The harness now models a worker that is asleep: a ping wakes it, and anything else posted while it sleeps is lost, which is what made the failure silent. `test_backpressure_is_real` read the first `worker.postMessage` in the function to check that the readable half is transferred rather than copied. The wake-up put a ping in front of it, so it began inspecting a call that carries only a port -- and kept passing. It now checks every post, each bounded by its own call, since the keep-alive ping transfers nothing at all. Same shape as the upload-seal contract this morning: a guard that reads "the first" stops guarding the moment something is inserted before it. Hub suite 851 passed. Both new cases checked against the unfixed source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat: resume an interrupted upload, and pause oneChristophe Besson2026-09-093-6/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 8 of ~/next/improve-downloads.md, second half, plus the gap it exposed in stage 7. **Asking where to resume.** The node identifies an upload by (member, directory, filename), so a client resuming one has to name the file — and `transfer_open`, the obvious place to ask, travels in clear. Naming it there would undo exactly what sealing this path bought in MNP 2.0: before it, the same file was ciphertext leaving a node and plaintext arriving at one. So the question is asked inside the seal that already exists, as an ordinary `file_upload` with no bytes and `chunk_index: -1`. The node writes nothing, creates no state, reserves no name, and answers with `resume_from` in the sealed ack. A node that predates it refuses the index, which the client reads as "start from the beginning" — the behaviour it had anyway — and the wait is bounded so one that answers neither does not strand an upload. The probe is answered after every check the write path makes, so it cannot ask questions about a directory the caller may not write to, and it answers only about the member who asks: otherwise one member could measure another's progress on a file they never sent, and worse, resume it. **Pausing an upload.** Reported: no pause button on an upload, even in the desktop app. Stage 7 built pause around the download path — a target declares whether it can be stopped — and an upload has no local target to ask. It was also refused by design, since a transfer handed a lease it cannot re-create must not be offered a button that would drop its slot for good. Uploads now ask for their slot rather than being handed one, and say they are pausable outright: a File is seekable and the node keeps the position. Resuming re-probes rather than trusting the client's own memory, so it works across a reconnect too. **And the slot they hold.** `_do_file_upload` never called `slots.touch(tr)`. Chunks are not gated by the lease, so the file arrived — but the node reclaimed a grant nobody appeared to be using after thirty seconds, twice, then abandoned it, and the widget follows the lease. Measured from the journal: a 3.5 GB upload read "waiting, 0 ahead" for a minute and a half while it was transferring. The download twin of this was fixed on 2026-09-08; the same omission was still here, invisible until uploads took a real lease. `test_the_upload_itself_is_sealed` now checks every message `uploadFile` sends rather than the first. Adding the probe put a second one in front of the one it was written for, and it would have kept passing while guarding nothing. Node suite 1202 passed, hub suite 850 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): stop reloading a healthy page at bootChristophe Besson2026-09-091-69/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported from Chrome: connecting to a group triggered a page refresh within seconds, taking the WebRTC session down with it. The boot check added with the bypass repair asked its question by *performing a download* -- a four-byte stream through a hidden iframe. Chrome rations the downloads a page may start without a user gesture to about three, measured: on a first visit three consecutive attempts went served, served, refused. So the check competed with the person's own downloads for that budget, and its answer depended on how much of the budget was left. On a healthy page it concluded the worker could not serve, and reloaded. The same mistake the repair was written to fix, from the other side: paying a capability to obtain a diagnostic. The replacement costs nothing and asks nothing. Measured on Chrome, at document start, before anything registers: first visit controller false, registration false ordinary reload controller true, registration true hard reload controller false, registration true Being uncontrolled while an active registration already exists names a hard-reloaded document exactly, so that is now the whole of the evidence. A first visit is uncontrolled too and is not a bypass -- the worker is installing and will claim the page in a moment -- which is precisely the case that was reloading. Four cases, each checked against the unfixed source, including that priming performs no download at all. Hub suite 847 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* docs(spa): say that a download with no folder cannot be pausedChristophe Besson2026-09-0910-10/+10
| | | | | | | | | | | | | | | | | | | | | | | | Reported from testing 7a: pause worked in the desktop app and no button appeared in Chrome. That is the design working — without a granted download folder, "save automatically" means the service worker, and that target is a download the browser already owns — but nothing anywhere said so, and choosing a folder looked like a question of where files land. So the Settings line now says what it costs not to choose one, in all ten catalogues. It renders only where a folder can be chosen at all, which is exactly the browsers the advice applies to. Also pins the tier table the pause button is drawn from: a granted folder, a save dialog and the desktop sink can be paused, a service-worker stream cannot. Four cases through the real `_openDownloadTarget`, and one more that reads the value off the real `downloads.js` rather than a stub of it -- the first version of these stubs did not carry the field at all, so the cases would have passed while checking nothing. Hub suite 847 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(spa): pause and resume a download, in sessionChristophe Besson2026-09-0915-26/+256
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 7a of ~/next/improve-downloads.md: pausing within a session, on the targets that can actually do it. Resuming across a reload is 7b. A paused transfer holds **nothing**. Its slot goes back to the node the moment it stops and resuming rejoins the queue at the tail, because anything else lets one member close a node by pausing four downloads and going to lunch. So the lease is taken inside the run loop rather than before it, and pause is refused outright for a transfer that could not ask for another one. Resuming is exact rather than approximate: the pipeline stops between two chunks and never inside one, so what is on disk is always a whole number of chunks and `fromChunk` is a verified position. The failure mode being avoided is a file that looks complete and is quietly corrupt. The target has to survive it, so a pause no longer reaches the `abort()` that a failure does -- that would delete Electron's `.part` or the file just created in the granted folder, leaving nothing to continue. And the in-memory fallback keeps its accumulated chunks rather than starting a second array. The button is drawn only where the target says it can. A service-worker stream says no, in its own code and for its own reasons: the browser is already writing an HTTP response into its own download folder, not feeding it stalls that download where we cannot see or resume it, and an idle worker is terminated within seconds. Firefox and Safari therefore keep cancel and get no pause, which is the decision recorded in §6.5. Cancelling a paused transfer ends it. A paused run is parked on a promise; without waking it the row said "cancelled" over work that had not stopped and a target that was still open. Six cases, each checked against the unfixed source. Hub suite 842 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): repair a bypassed page in seconds, not half a minuteChristophe Besson2026-09-091-1/+19
| | | | | | | | | | | | | | | | | | | | | | | | | The repair worked but arrived too late to help: about thirty seconds after a hard reload, by which time four downloads had been started and hung, and the page reloading under them read as an unexplained refresh. Two delays, both removed. `_claimController` waited its whole control budget before asking for the claim. A page that is uncontrolled while an active worker exists will never be claimed on its own -- a document fetched by a hard reload is exactly that shape -- so the fifteen seconds were spent waiting for something that was not coming. The claim is now asked for first; waiting is the fallback, not the opening move. Measured in the harness: 6042ms of a 6000ms budget before, milliseconds after. And a download that starts while the self-test is still running now waits for it rather than racing it. Otherwise the click spends both its attempts failing on a path that is about to be repaired, which is what put four frozen rows on screen. Hub suite 836 passed. Both new cases were checked against the unfixed source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): repair a page the download worker cannot serveChristophe Besson2026-09-0911-12/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Downloads on Firefox failed with "the worker did not answer the download within 15s", every time, for one operator, while the same profile driven from here succeeded every time. Their own test sequence found it: a freshly started browser downloaded four files out of four, twice; one Ctrl+F5 and every attempt afterwards failed; restart, fine again; Ctrl+F5 before any attempt and the very first one failed. A document fetched by a hard reload is loaded with the service worker bypassed. It can still be claimed afterwards, so `navigator.serviceWorker.controller` comes back and every check in `_claimController` passes — but the navigations that document starts keep missing the worker, and the hidden iframe a streamed download needs is a navigation. On Firefox and Safari that is the only way to write a file too large to hold in memory, so the download cannot happen at all, for the life of the page. Being controlled is not being servable, so priming now asks the question directly instead of inferring it: a four-byte stream and a hidden iframe, exactly as a real download would, torn down completely so nothing lands in the download folder. When it goes unanswered the page reloads once, ordinarily, which puts it back under the worker. The flag lives in sessionStorage rather than a variable because it has to survive the reload it triggers, and because a page that is still unservable afterwards must stop rather than loop. Also stops telling people to change browser. The message said "use the desktop app, or Chrome or Edge" for a state an ordinary reload undoes, on the one path Firefox has no alternative to; all ten catalogues now say to reload first. The hard reloads were on my instruction: the SPA's HTML is served `no-store`, so a plain reload has always picked up a new build and Ctrl+F5 was never needed. Hub suite 834 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): nothing on the worker path may wait for everChristophe Besson2026-09-091-7/+77
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Four downloads on Firefox sat at "preparing" indefinitely, with the target queue already bypassed there, so each opening was hanging on its own. The node journal showed `d=0/8(q0) u=0/8(q0)` — no transfer had been asked for yet. `_claimController` had two waits with no deadline at all, `navigator.serviceWorker.register()` and `navigator.serviceWorker.ready`, while SW_CONTROL_BUDGET_MS bounded only the wait that comes after them. `_swPromise` is shared, so one unsettled wait left every download on the page suspended on the same promise for the life of the tab. Measured on Firefox 154, against a local 127.0.0.1 site so no hub was involved: a worker that installs gives register() in 8ms and ready in 0ms; a worker whose install handler rejects gives register() in 7ms and a `ready` that never settles — still pending past ten seconds. register() resolves as soon as the registration object exists, carrying nothing but an *installing* worker; ready is what waits for an active one. Every wait is now inside one budget, with two carve-outs so that a deadline never costs a capability. A `ready` that times out while registration.active is set is not fatal: ready may be waiting on a newer worker that cannot install while an older one serves perfectly well. And the mbdl-claim recovery keeps its own budget outside the deadline, because giving up there would cost Firefox the only unbounded way it has to write a download to disk. A deadline alone would have been a better-explained failure rather than a fix: a registration stuck with nothing but an installing worker does not heal, and every later visit finds the same one. So when ready times out with no active worker, the registration is discarded and asked for once more with a fresh budget, and the page repairs itself instead of needing developer tools. Four cases pinned, each checked against the unfixed source. Hub suite 830 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): the target queue must not be able to freeze a batchChristophe Besson2026-09-091-1/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Four downloads on Firefox all sat at "preparing", with the node journal showing `d=0/8(q0) u=0/8(q0)` — not one transfer opened, so nothing had got past the client's target opening. Serialising those openings was new in d6c4808, and on Firefox it regressed what had always worked: four openings that ran at the same time began waiting on the slowest. `_targetQueue` is module-level and never reset, so an opening that never settles leaves the page unable to start any download again until it is reloaded. Two bounds, both narrowings of the queue rather than of any capability: Only an opening that could actually show a dialog joins it. Firefox and Safari have no `showSaveFilePicker` at all, so nothing there can race anything and the queue bought nothing while costing everything; they now bypass it entirely, which restores the previous behaviour by construction rather than by tuning. And no opening waits behind another for longer than TARGET_QUEUE_BUDGET_MS (90s) — generous enough never to cut in front of a real dialog, finite because the alternative is a download panel that only a reload can fix. Releasing early is safe: whatever is ahead is still the only unbatched opening, so the released one takes the streamed path and opens no second dialog. Measured on Firefox 154 against the deployed hub before writing any of this: `register` and `ready` return instantly, the page is controlled, and four serialised openings are served in 5-18 ms. The streamed path was never the delay; the queue was. Both new cases were checked against the unfixed source: without the bypass the peak concurrency is 1 instead of 4, and without the budget the stuck-opening case hangs. Hub suite 826 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): one save dialog per batch, not one per fileChristophe Besson2026-09-091-4/+54
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Selecting four files on Chrome produced a Save As dialog for the first, then one for the second only after that file had finished, while the last two timed out; on a later attempt the three remaining transfers appeared frozen. Two things were going on. Opening the target inside `prepare` had removed the accidental serialisation that `for (…) await downloadFile(e)` used to provide, so `_openTargetInTurn` now queues the openings — but a queue whose head is an unanswered dialog is a head-of-line block, which is what the "freeze" was. The code already recovered from a picker with no gesture behind it by streaming instead, on the `SecurityError` Chrome throws. That branch was never reached: Chrome does not throw, it shows the dialog anyway and waits for a human. So anything that has to wait its turn is now marked `batched`, and a batched opening prefers the streamed path whatever the download mode says. The first file of a batch — the one actually holding the gesture — still gets its dialog, so the preference is honoured where it can be. For the rest there is no gesture left to spend and nothing is lost by streaming: the file still lands on disk, in the browser's own download folder. Only the choice of folder goes, and it was not on offer. If the worker does not answer, a batched download falls back to the dialog rather than failing. Also logs which path led to a dialog. A dialog is the one outcome nobody can diagnose after the fact — it looks the same whether it was asked for or fallen back to — and the report this fixes needed three test cycles to narrow. The two harnesses that lift `_openDownloadTarget` as text now route console.info to stderr, since they parse stdout as JSON. Measured against the deployed hub in Chrome 152: the streamed path serves the hidden iframe in 2-3 ms on a normal load, after a hard reload (via the `mbdl-claim` recovery already in `_claimController`), and twice in the same document. Hub suite 824 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): the transfers row exists from the clickChristophe Besson2026-09-0813-85/+149
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking Download produced nothing — no row, no icon, no panel — for as long as it took to open somewhere to write, and then several rows at once. The streamed path waits for the worker twice; a Save As dialog waits for a person. The row was created after that, so the slowest part of a download happened with nothing on screen to say it had begun. The store gains a `prepare` step, distinct from `run`, and the order is now: row, then target, then slot. That last part is why the obvious fix was wrong. Taking the slot first would let the row appear immediately, and it was tried this morning: a granted slot has to be taken up within the node's deadline, opening a target can outlast it, and three downloads became one. (The diagnosis at the time blamed that ordering for revocations which were in fact a missing `touch()` call — the revert was right for the wrong reason.) `makeLease` is called after `prepare` succeeds, never before. Three behaviours fall out, each with a test: - a dismissed dialog leaves nothing behind. `prepare` returning false drops the row: nothing started, so nothing should remain on screen to explain it; - the row takes the name the file was actually saved under, once known; - a refusal above the memory ceiling fails the row that is already there, rather than creating one to kill it. `preparing` counts as live everywhere — badge, cancel, clearFinished, and `_busy`, since closing a transport under a preparing transfer strands it exactly as under a queued one. Six places asked "is this finished?" and were drifting apart; there is one definition now. Two mistakes in the tests, worth the note: one counted positions in an output array by hand and was one out, which reads exactly like a failing assertion about the code — the values are tagged now, not indexed. And test_zip_size_limit.py's stub did not run `prepare`, so it no longer reached the size check the file is about; it now behaves like the real store. 819 hub, 1169 node, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(hub): client-side transfer leases and the transfers panelChristophe Besson2026-09-0817-77/+715
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots since step 2 and nothing asked for one; now the client does, and the panel shows what is happening. `transport.openTransfer()` returns a Lease: `acquire()` resolves when the node grants, `release()` gives it back exactly once, and nothing else in the client speaks to the node about slots. Whether a node hands out slots is read from the handshake ack rather than guessed from a timeout — "no answer yet" and "this node will never answer" are indistinguishable in time, and guessing wrong either stalls every download or defeats the cap. Two things exist only because a queue can lie: a watchdog re-asks when a pushed grant does not arrive (the node is idempotent on `tr`, so asking again is free), and a grant for a transfer the page has forgotten is handed straight back rather than held until the node's deadline. The slot is asked for **after** there is somewhere to write, and that ordering is load-bearing: opening a target takes thirty seconds of streamed-download timeouts, or as long as somebody leaves a Save As dialog open, and a grant not taken up in time is revoked. Moving it earlier looked better and broke three downloads into one. Pinned by a test. The panel groups by state — running, waiting, finished — rather than re-sorting a flat list, so a row moves only when its own state does. The ETA is withheld until the speed window holds real measurement: a figure from the first two chunks swings between four seconds and an hour, and people plan around the first number they see. One live region announces state changes and not progress. Three silent paths closed on the way: a download refused for want of a user gesture (a browser grants one file picker per gesture, and downloading three files is one gesture) now falls back to the streamed path, which needs none; a click with no connection says so instead of doing nothing at all; and a queued transfer counts as busy, so a transport is never closed under one that is waiting for a grant that could then never arrive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): keep the download worker alive, and never hang on a dead sinkChristophe Besson2026-09-082-3/+79
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A download froze part-way through, on Firefox, with an empty console and a node that stayed perfectly healthy. Three separate measurements cleared the node (615 MB pulled whole over MNP), the transport (three files interleaved on one connection, 1.5 GB, all whole) and the service worker (three concurrent 150 MB streams in real Firefox 154) — because none of them was wrong. The empty console was the evidence. `_sendAndWait` logs every timeout, so no chunk request had expired: the client was not waiting on the node. Of the three awaits left on that path only one was unbounded. **A service worker with no event for about thirty seconds is terminated**, and `respondWith(new Response(stream))` does not extend its life while the response is still being written. The reader vanished mid-file and `writable.write()` then never resolved and never rejected — no error, no log, no failed transfer, just a progress bar that stops. The first stress probe wrote 450 MB in two seconds and passed: fast enough to hide it entirely. Measured in Firefox 154, writing 1 MB every 2 s: without the ping it stalled at 17 MB after 59 s; with it, 40 MB in 80 s, complete. - the page pings the worker every 10 s while it writes, and the worker answers. Receiving a message is an event, and an event resets the timer; - that interval stops itself after two minutes with no write. A target can be opened and never written to — a transfer cancelled while it waits for a slot never runs, so nothing calls close() or abort() — and a timer nobody clears pings for the life of the page. It also kept the Node test process alive for ever, which is the same defect wearing a louder symptom; - `writable.write()` is bounded at 60 s and fails with a message naming the chunk. That does not fix whatever stopped a sink; it turns an unexplainable freeze into a failed transfer that says so, which is the difference between a mystery and a bug report. Also: `Content-Disposition` lost a filename to a single apostrophe. `encodeURIComponent` leaves `'` alone and `'` is the delimiter in RFC 5987's `filename*=<charset>'<lang>'<value>`, so the header became unparseable and Firefox named the file after the URL — 449 MB of film arrived complete as "mtsshk9w-ohqty535". `(`, `)` and `*` get the same treatment, and a plain ASCII `filename=` rides alongside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): let this origin frame its own download URLChristophe Besson2026-09-082-3/+40
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three headers govern whether a page may be framed, and all three had to be wrong for the streamed download to work — so fixing them one at a time cost an afternoon of redeploys and retests. They were visible together in a single `curl -I` against the deployed hub, which is where this should have started. The streamed-download path navigates a hidden iframe to `/_mbdl/<id>` so the service worker is asked for the response it is holding. On Firefox and Safari that is the only way to write a large file to disk: neither has the File System Access API, and OPFS is capped at 10% of the volume's size (measured on Firefox 154: 389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte), which a film exceeds. - `frame-src` was reCAPTCHA's two origins with no `'self'`, so the frame could not be loaded at all. Added when the captcha needed a frame; nobody connected the two. - `frame-ancestors 'none'` forbids all framing, this origin included. - `X-Frame-Options: DENY` says the same in an older dialect. The spec says a browser must ignore it when frame-ancestors is present — relying on that while shipping a header that contradicts our own policy is asking to be surprised, and we were: the CSP was fixed and the download stayed broken. `'self'` and `SAMEORIGIN` refuse every foreign origin exactly as `'none'` and `DENY` do. The clickjacking property is untouched; what they additionally allow is this origin framing itself, which is the only thing the download needed. Pinned three ways: `frame-src` must carry `'self'`, `frame-ancestors` must be `'none'` or `'self'` and never name an origin, and the two framing headers must agree — the defect was the disagreement, and either one read as correct alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(node): make the transfer caps settable, node-wide and per groupChristophe Besson2026-09-0811-0/+34
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Step 3 of ~/next/improve-downloads.md. Step 2 built the pools with constants; this gives them to the operator, in the two scopes they belong to. **The pools are the machine's.** `[node] max_concurrent_downloads` and `max_concurrent_uploads`, default 8, on the §2.11 pattern: node.toml for a fresh install, a roster.db override for immediate effect, editable from the Node page and from `meshbay-node transfers show|set`, applied live through the one `set_capacity` step 1 fixed. **The per-member cap is a group's.** How many transfers one member may run at once here — on the node like every other group setting (not the hub, which would have authority over someone else's disk; not node.toml, which is hand-written and needs a restart), changed by a signed operator instruction (`OP_TRANSFER_LIMITS`, subject "d=2,u=2" so what is signed names the outcome), broadcast to the group, and read live by the pools. That was the one thing step 2's shape could not express: `per_member` was a single node-wide number. `group_limits` and `member_cap(kind, member)` make it a lookup — the group's own value if it has one, the node's default otherwise — and it is deliberately the only dimension that is not node-wide. Three refusals, each with a test: - **absent means the default (2), never "unlimited".** A group that predates the setting coming back unlimited would leave the node-wide pool as the only control, which is the situation slots exist to end; - **zero is not "unlimited"**, and is not "this member may not transfer" either: the floor is one everywhere, and the CLI says to revoke the member instead; - **an unreadable row reads as unset**, not as zero — the same discipline the sealed messages follow, where a payload that does not open must never become a default state on its own. `handshake_ack` carries this member's own caps for this group, so the interface can say "2 of your 2 slots are busy" instead of drawing a bare spinner. Absent reads as "no limit known" and the hint is not drawn — never as "unlimited", which would have the interface contradicting the node. 1164 node, 793 hub, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): never collect a large download in the pageChristophe Besson2026-09-0812-10/+133
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `pipelinedDownload` with no writable allocates `new Array(totalChunks)` and keeps every decrypted chunk, so whatever `_openDownloadTarget` returns null for is held whole in RAM. That floor had no upper bound: the `!window.showSaveFilePicker` branch returned null at any size, so on a browser without the File System Access API a 20 GB film went to memory whenever the streamed path did not answer. Nothing logged, nothing refused; the symptom was the tab dying, with no error attributable to this code. MEMORY_CEILING is 100 MB and every `return null` in that chain now goes through a guard that throws above it. The refusal names the size, the limit and why the streamed path declined, and lands in the transfers panel as a failed transfer rather than in a console nobody opens. This is a guard, not a limit on what can be downloaded: with the streamed path primed and retried (previous commit), a file of any size still goes to disk progressively on every browser. Two things had to change for that to be true: - the streamed path is now tried in "ask" mode too, for a file over the ceiling on a browser with no Save As of its own. The mode decides whether to show a dialog; it was silently deciding whether a film could be downloaded at all; - FilePreview had no size check whatsoever — a multi-gigabyte PDF or .csv was fetched whole, and the text branch decoded all of it to keep 500 000 characters. It refuses above the same ceiling and offers the download. ZIP_MAX_BYTES (512 MB) and the ceiling do not contradict: the archive limit bounds the archive, the ceiling bounds what may be built in the page, so a 400 MB zip is allowed when there is somewhere to stream it and refused when the only route left is memory. The build-in-memory confirmation only appears below the ceiling now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): make the streamed download path reliable, and clean up after an abortChristophe Besson2026-09-083-37/+183
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | On Firefox and Safari the service worker is the only unbounded way to write a download to disk: neither has the File System Access API, and OPFS is not a substitute — measured on Firefox 154, its quota is exactly 10% of the volume's size (389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte), which a film exceeds. So when this path declines, a large download has nowhere left to go, which makes its reliability a correctness property. Four ways it declined, all of them avoidable: - it was registered inside the first click on Download, so that click paid install, activate and claim while somebody watched a button do nothing; - `_swReady` cached a null for the life of the page. One slow first click left the tab unable to stream anything again, curable only by a reload nobody knew to do. Only a successful controller is remembered now; - control was waited for with a 3 s cap. It is 15 s, and a page that is active but not controlled asks the worker to claim again (`mbdl-claim`) instead of declaring the path unavailable; - a missed navigation gave up at once. It gets a second attempt with a fresh id and iframe, the failed one torn down completely first. Also closes a MessagePort leaked per download, and gives the reason a name (`lastStreamFailure`) so a refusal can say what happened. The timeouts became parameters: the defaults are the production values, no caller passes any, and the tests do not spend a minute waiting. `openTarget` gets an unrelated but adjacent fix, in the same file: it creates the destination with `getFileHandle({create: true})`, so an empty file exists before the first byte, and `abort()` leaves the target untouched — every cancelled download left a 0-byte file behind, and since `freeName` avoids collisions, three cancels left film.mkv, film (2).mkv and film (3).mkv, all empty. Its `abort()` now removes the entry. Safe here and only here, because `freeName` guarantees the name was not taken: the `showSaveFilePicker` path must not do the same, where the person may have picked an existing file whose contents `abort()` correctly preserves. Verified by hand in Chrome. test_streamed_download_reliability.py runs the real module under Node against a stubbed browser — it fails if the null is cached again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* chore: bump all packages to 0.12.0Christophe Besson2026-09-081-1/+1
| | | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(client): create the system tray at launch, not on first minimiseChristophe Besson2026-09-082-6/+31
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ensureTray() was reachable only from the window:minimize-to-tray handler, so the indicator did not exist until you had already hidden the window into it. That is backwards on both desktops — most of what a tray is for is finding an application that is not in front of you — and on Windows it read as the app having no tray presence at all. Created during app.whenReady(), after registerBridge() and before createWindow(). The order matters: buildTrayMenu reads the nodeService that registerBridge assigns, so the other way round puts the Start/Stop entry on the menu one five-second poll late. The menu's labels were the one thing that came *from* the minimise call, since the main process has no i18n. A new tray:labels IPC (platform.setTrayLabels) carries them instead, sent from the renderer's boot once initLocale() has a catalogue; a language change reloads the page, so the same call covers it. The window between launch and that first message shows TRAY_FALLBACK, in English. §5.10's two platform gates become one — trayOS() in main.js, which every tray path calls. test_desktop_shell.py's existing test is rewritten against it and two are added: the launch ordering, and that no tray path tests process.platform inline instead of calling the gate. Windows still files a new tray icon under hidden icons until the person drags it onto the taskbar. No API promotes it; documented in WINDOWS-PORT.md §5.11 rather than worked around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* fix(hub): drop the flag emoji from the language menuChristophe Besson2026-09-082-11/+18
| | | | | | | | | | | | | | | | | | | The flags were regional-indicator pairs, which Windows has never shipped a glyph for: Segoe UI Emoji renders the pair as its two letters, so the menu read "GB", "FR", "NL" down the left-hand side instead of a flag. It only ever looked right on a system with an emoji font that draws them, such as GNOME's. Names only, rather than vendoring ten flag images. A flag is a country and not a language anyway — none of them is the right answer for "Português (Brasil)" — and each name is already written in its own language, which is the self-identifying signal the flag was standing in for. The `flag` field is removed from LOCALES rather than left unread; app.js's menu was its only consumer. The submenu keeps its indent from .user-menu-sub's padding, not from the icon box that is now gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(hub): name a group's host on its card, not its visibilityChristophe Besson2026-09-083-7/+21
| | | | | | | | | | | | | | | | | | | | | | "private" and "invite" were on every card because they are the default for every group on the hub — two badges that never varied and so never told anyone anything. Who hosts a group does vary, and it is the thing that tells two groups of the same name apart: the hub enforces name uniqueness per owner account, not globally. - The "My groups" cards (app.js) drop both badges and render the name through GroupName, which puts the owner beside it as a smaller "@handle" — the same way the group header and Explore already write it. "admin" stays; it varies. /v1/groups/mine already returns owner_username, so no API change. - Explore drops its join_policy badge for the same reason. An open group still announces itself through the Join button, which is the useful half, and its @host was already there. - .group-card h3 .gn-owner is set to 0.8em: a card's h3 is reset to 1em, so the 0.55em meant for a page heading rendered the handle at about half the body size — smaller than the name, as it should be, but past readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(hub): collapse "Your devices on this node" by defaultChristophe Besson2026-09-081-3/+8
| | | | | | | | | | | | | | | | | | It is the one section in group settings that is open on arrival while having nothing to say in the ordinary case: the device you are reading it on is already linked, and the panel exists for two rare errands — approving another device, or removing one. Uses the CollapsibleSection the sections around it already use, with defaultOpen={false}; the component renders the same .settings-section wrapper, so nothing about the layout moves. The prompt that *is* actionable — "this browser is not linked to this node yet" (device.add_title) — lives in group-page.js and is untouched, so a browser that genuinely needs linking still says so on arrival. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(hub): cap a directory zip at 512 MBChristophe Besson2026-09-0811-5/+49
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An arbitrary ceiling, not a technical one: the zip writer streams and holds one chunk plus a record per file, so it would happily produce a hundred gigabytes. Past half a gigabyte the honest answer is a subfolder at a time, or the files individually. Enforced in file-utils.js's downloadDirectory, which is the one implementation behind every zip button — Files' single folder, Files' multi-folder selection, and the Photos album button (docs/photos.md §3). - Per directory, not per selection: Files zips a whole multi-directory selection in one click, so an oversized folder is refused and its siblings still download. - Before _openDownloadTarget, so no save dialog opens for an archive that is never going to be written. - The bound is strict, so a folder of exactly 512 MB still goes through. - Counted in the 1024-based units formatSize already prints, so the number in the refusal is the number in the constant. group.zip_too_large in all ten catalogues. test_zip_size_limit.py runs the module under Node and pins the refusal, the inclusive bound, and that nothing is asked or started when a folder is over. The user guide's "a 40 GB folder costs 40 GB of disk" is no longer true and now documents the cap instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* fix(hub): stop badging every poster "unmatched" when TMDB is offChristophe Besson2026-09-081-7/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The dashed warning outline and the "?" flag say a TMDB lookup ran and came back with nothing — that is how an operator finds the titles worth a "Fix match". With TMDB switched off for the group no lookup is made at all: media_meta_req answers confidence 0 for the whole library by design, so the poster grid drew every card as a failure of the very thing the operator chose, with no poster ever coming to clear it. Poster mode now shows the file's own thumbnail plainly in that case, the same as Flat does, and keeps the badge for what it was built for. - PosterCard takes tmdbEnabled and gates both the outline and the "?" on `tmdbEnabled && !confident`. The title still falls back to the parsed filename exactly as before, and the ready/imageReady anti-flash gating is untouched. - VideoDetailModal gates its "no confident match" paragraph the same way. A show still opens that modal with TMDB off — it is where the season list lives — so the card alone would have left the claim one click deeper. - Its operator-only "Fix match" / "Rematch" buttons are gated too: both act on a match that does not exist, one opening a search the node answers empty, the other dropping a cached match never made. Those buttons are the only entry points to TmdbSearchOverlay and doRematch. - Both props default to true, so a call site that forgets one keeps the badge rather than silently losing it. search-page.js is deliberately left passing `enabled: true`: a cross-group view has no single group's switch to read, and it never showed the operator buttons anyway (isNodeAdmin={false}). Recorded as §10.7 in docs/mediacenter.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(chat): Tier 2 — a member verifies another member's device itselfChristophe Besson2026-09-0714-0/+228
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Chat messages have been signed by the sending device since MNP 2.0, but a reader had no way to know that the device belonged to the account the node named: the signature proved *a device*, and `sender_id` was still the node's word. This closes that for any account a client has already seen. **What was blocking it was not effort — the evidence was not being kept.** `_do_device_add` verified the countersignature that admits a second device and stored only `added_by_pk`: *which* key approved, never the proof. And `device_add_transcript` binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who had not been on that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added before the migration's early return — which fires on every roster widened since 2026-08-18, i.e. all of them, so putting them inside it would have meant they never arrived. `group_roster_req`/`resp` relays, sealed under a new groupbox purpose and answered to **any member of the group**, every live device of every active member with the evidence that admitted it. The node decides nothing: it hands over evidence and the client walks the chain from each account's root outwards (`_verifyRoster`). That is deliberate — the node is the party the property holds against, so it is not asked to assert trust. Two holes the tests caught while this was being built: - "no signature" was being treated as a trust root, so a node that writes the roster could put any key in an account's row and have it laundered straight into the verified set. A root is a device that names **no** countersigner. - pinning only the verified subset at first sight raised "key changed" on legitimate second devices whose countersignature predates this change. First sight pins everything the node says, because that is what trust-on-first-use means and an alarm that fires on normal events stops being read. The property, and it must not be rounded up: **once a client has seen an account, a node that later substitutes a key for it is detected. Nothing is gained at first sight**, where there is nothing to compare against — the same boundary `per-node-identity-v1.md` draws, unmoved. The cost, stated because it is real: the roster is member-visible, so every member learns how many devices the others hold and their public keys. It stays inside the group, the hub is not involved, and it is scoped per group. A member who cannot see the keys cannot check them. User-visible surface: one notice, "this account is using a key you have not seen before", in ten languages. Nothing else. 16 tests — 7 on the node (the evidence is stored, it verifies from the roster alone, a fabricated device carries none, another group's members are not disclosed), 9 running the shipped `_verifyRoster` under node against rosters built by the shipped Python: a chain of three in any order, a signature by the wrong key, one for another node, one for another account, and two fabricated devices signing each other admitting nothing. Tier 3 (operator-signed roster attestation) stays deferred, with nothing depending on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
* Merge origin/main into the chat encryption workChristophe Besson2026-09-072-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