| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
| |
spa_source.transport_files() takes the classic transport*.js scripts
from the hub's shell, in load order. Every test that read transport.js
reads them all, the Node harnesses run them joined as one scope, the
chat probe loads each, and the desktop shell must load them in order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The Node harnesses take one file or several, and the tests that lift
connectToGroup and the pool out of search-page.js as text take them from
spa_source, which also reads connection-pool.js once it exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
dead node
The other half of the 4G failure. The page negotiated every group twice: the
sweep opened a connection, read the index and closed it, then the warm-up
opened the same group again. The sweep, the warm-up and the tiles each had a
concurrency ceiling of their own, and together they went past what the hub
admits per account. Whatever the hub refused was then reported as "node
unreachable" and remembered as down, which put that group last next time.
- Every connection goes through ConnectionPool, which holds the page's one
ceiling (six at once, sized for twenty groups on a phone) and keeps what
the sweep opened for the tiles. A visit costs one offer per group. A
refresh costs none for a connection that answers a four-second ping, and
a connection that died while the phone slept is replaced, not waited on.
- A connection whose index is being read is held against eviction. With
more groups than the pool keeps, it was otherwise the least recently used
one.
- Negotiations still under way when the page closes close what they get,
and a sweep cut short that way remembers nobody as down.
- transport.js sends an offer again on 429, 502 or 503, honouring
Retry-After, with jittered waits of about twenty seconds at worst. Search
counts each retry as progress. A 404, 403 or 504 still fails at once, so
a dead node costs no time.
The fan-out tests assumed a ceiling of three and were re-measured: four dead
groups of twelve now hold nothing back, even on a first visit. The pool and
the retry run as shipped code, lifted as text, against a fake clock. Each
guard was checked by removing it and seeing its test fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
#/invite takes the link out of the address on load and keeps it in the
tab through registration and sign-in; joining is one click, only the
ticket goes to the hub, and the code goes only to the node the link
names once it has signed its challenge. Members tab gains "Invite by
link" (shared e-mail box, pending list, cancel both halves); home page
takes a pasted link. Browser probe drives the real app, signed out and in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The toolbar's actions on the row under the pointer, sharing one action list
with the toolbar — which keeps showing what does not apply, disabled, while
the menu leaves it out. A count only where more than one item is concerned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
A native confirm() or alert() leaves the desktop client unable to type
until the window is refocused. ask.js draws both in the page; the SPA
test now bans all three browser dialogs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
A browser limits bytes, so a bound in seconds had to be sized for the
highest-bitrate file and every ordinary one then held a fraction of what
the same buffer would have taken. Floored at the old 90 s so nothing
pulls less than before, and walked up rather than declared. A refused
append now waits for an eviction instead of retrying on every tick.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`test_sticky_header.py[firefox]` failed with twelve setup errors whenever
a Firefox was open, and the note in CLAUDE.md said to close it and trust
the Chrome half meanwhile. The cause was not snap's single instance: the
harness pointed HOME at a throwaway directory, snapd sets its own HOME
inside the sandbox, and that directory came back empty on every run — so
Firefox opened the real profile, which the open browser locks.
`--profile` needs a path the sandbox can see: the snap has a private
/tmp, and its home interface grants no hidden directory. Both refusals
print "Firefox is already running", which it also prints when nothing is.
24 passed in 15s with a Firefox open throughout, against 12 errors in 60s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`.admin-table` column heads are `white-space: nowrap`, which meets a
translated string: "Username" is one word, "Nom d'utilisateur" is three,
uppercased with letter-spacing on top. The heads alone overflowed a 360px
window by 11px, and on Android an overflowing document takes every sticky
element with it.
11px was only what could be measured: the probe served no members, so the
table measured its headers and nothing else. With one realistic username
in it the same page overflowed by 248px, at 420px-wide windows too. The
cells get `word-break: break-word`, as `.file-name` already had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The pass before this applied ruff's own fixes. These are the ones needing a
decision, and the point of doing them is that `ruff check .` now passes: a
linter reporting 98 known-acceptable findings reports nothing, because the next
real one arrives invisible.
**Lines over 100 (70).** Mostly wrapped where they stood. Two exceptions: the
aligned trailing comments in `protocol.py`'s message table were shortened rather
than wrapped, because wrapping one row of a table breaks the table; and in
`models.py` the column comments moved above their columns for the same reason.
**Imports below the first statement (14).** `csam.py` kept its FastAPI imports
under a section header halfway down the file; two node tests had a constant and
a `pytestmark` wedged between two import blocks. Moved, not suppressed.
**Bindings nothing reads (4).** Three in tests, where the call stays and only the
name goes — `_user(client, "listener")` is there to create the user, not to
return one. The fourth was in `revocation.py` and was not a lint finding at all:
`_connect_and_listen` opened an httpx stream to the WebSocket URL, did `pass`,
and then opened the real connection through the `websockets` library. One
pointless request per connect, left over from before that library was used
directly. Removed, and `httpx` with it.
**`l` as a name (4)**, **semicolons (6)** in the POC spikes, and the rest.
2893 passed, the same count as the two commits before it.
`meshbay_node/revocation.py` is worth a decision separately: 154 lines that
nothing imports, superseded by `hub_client.maintain_ws`'s `on_revocation`. This
commit only stopped it failing the linter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`ruff check .` had gone unrun long enough to report 568 errors, which is the
same as having no linter: the next real finding would have been invisible in the
noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the
98 it cannot fix are the next commit.
What actually changed: import sorting (225), imports nobody used (87, none of
them a re-export — no `__init__.py` is touched, which was the one way this could
have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69)
and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12
this project requires, `Optional[X]` to `X | None` (24), and f-strings with
nothing to interpolate (19).
Checked rather than assumed: every module in the three packages still imports,
and the suite is 2893 passed — the same count, test for test, as the merge
before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The grid's unit is an album, so a track whose artist tag is empty was
drawn nowhere — while `empty` counted it and stayed false, so no message
appeared either. An untagged library rendered a toolbar over a blank
page, with every track one mode-switch away and nothing saying so.
It gets a card, the same shape the singleton folding already mints. No
cover is looked up for it, or for any album this file invented: the
release name is one the browser wrote, and the request cannot match.
music_untagged_probe.py renders the real grid and reads the page back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
_reconnectLoop re-did the handshake and nothing else, so a page kept
whatever it last saw until someone reloaded it. That is invisible until
the node restarts: it rebuilds its index from index_cache.db, which
holds no enrichment, and Music is the one app whose enrichment is
persisted nowhere — for the length of the re-read pass it serves tracks
with no artist, and the album grid drew nothing.
onReconnected was one slot the video player took on open and cleared on
close; it is a listener set now, and carries the fresh ack.
docs/MESHBAY_DESIGN.md §15.3 records the two defects found alongside and
not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`group_indexes` was an IndexedDB store holding a decrypted copy of each group's
index — every file's name, path, size, hash and uploader — written on every index
and on every delta, from three call sites.
It was the cross-group search of Phase 10b: `doSearch` read `getAllCachedIndexes`
and searched those records instead of dialling anything. On 2026-08-28 Search
began dialling the nodes, and that commit removed the reader and left the writers.
Since then the browser has gone on building a cleartext file listing that nothing
consulted, that no sign-out removed — the key database is a different one — and
that grew with every group ever opened. L7, at rest: kept code that nothing calls
does not sit still.
Drawing a group's files while its node is unreachable is the only thing such a
cache buys, and it is not wanted: a listing that cannot be opened is worse than an
honest absence. So there is nothing to read it with, and the writers go.
The store stays in the schema and is emptied instead. Dropping it needs a version
bump, a version bump is an upgrade another tab can block, and playlists share this
database — so the tidier change is the one with a failure mode. `purgeGroupIndexCache`
runs once per browser behind a flag, which clears what is already on people's
machines; a browser that refuses storage simply runs it again, which is harmless
because it is idempotent.
Three guards, each checked by reintroducing the fault: only `openDB` and the purge
may touch the store, the purge may only clear it, and the purge must actually be
called at start-up — a purge nobody calls is the same defect wearing the opposite
hat.
`test_sticky_header.py[firefox]` reports twelve setup errors in a full run here.
A Firefox instance is open on this machine, which is the trap CLAUDE.md describes;
the same twelve appear with these changes stashed, and the `[chrome]` half of the
same file, covering the same geometry, is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A node is a machine in somebody's house, so with a handful of groups one is
always off. Search treated that as the exception and charged the reader for it.
Measured on twelve groups against a virtual clock, a live group answering in
200 ms and a dead one taking the full deadline: one node down in the first batch
put a blank page and a progress bar in front of the reader for **ten seconds**,
while two groups of that same batch had answered in two hundred milliseconds and
nine others had not been dialled at all. Four down, spread out — the shape a real
reader has — was ten seconds to the first result and **forty** to the last.
Two causes, neither of them the connection deadline. Results were drawn once a
batch was complete rather than as each arrived, so an index already in hand waited
on a node that was not answering. And the batches were sequential, so a dead group
did not merely cost its own deadline, it postponed every group behind it.
So the three is a ceiling on concurrency and never a batch — `inFlight` starts the
next group the moment one ends — and each index is drawn when it lands.
That alone is not enough, and the tests say where it stops: a ceiling still lets
silent nodes hold every place at once, and with four of twelve down the last three
live groups still waited out a deadline. So the browser remembers which groups
were silent and dials them last, which puts all eight on screen in 600 ms. The
list is advisory and rewritten from what each sweep saw: a private window, storage
that refuses, a first visit or a node that has come back all fall through to the
hub's own order, cost one sweep, and correct themselves.
The "n groups unreachable" line also waited for the sweep to finish, which is the
one moment it is no longer needed. It now appears as they are found.
First result, before and after, twelve groups: one down 10 s → 200 ms; four down
10 s → 200 ms with every reachable group on screen by 600 ms; three down and
listed first 10 s → 200 ms from the second visit on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Opening a group from the sidebar has no deadline of its own and gets the
transport's: 30 s for the DataChannel, 30 s for each request after it. Search
wrapped the same `connect()` in a flat 10 s, and that 10 s had to cover the hub
round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the channel
opening and the handshake's own round trips. On a phone on 4G the budget was met
by luck rather than margin, and the same group then failed in Search while it
opened from the sidebar, against the same node. The budgets were inverted: the
phase full of round trips had a third of what one request on an open channel got.
Raising the number would have been the wrong repair. `fetchAllIndexes` fans out
three at a time and waits for the slowest of each batch, so a page of unreachable
groups costs batches x the deadline in spinner: a bigger number taxes every dead
group for the sake of the live ones.
So the deadline measures stalling. A node that is not there reports nothing and
still fails in `SEARCH_STALL_MS`, unchanged at 10 s, which is what keeps the
fan-out where it was. A node that answers ICE, then opens a channel, buys another
window at each step, up to `SEARCH_MAX_MS` — a deadline that only ever resets has
none, and a node that answers and then goes quiet would otherwise never be given
up on.
The transport reports those steps through `onConnectProgress`, set by the one
caller that imposes a deadline of its own. `connected`/`completed` is the signal
and not `checking`, because the first means a candidate pair answered and the
second means this side is still trying addresses that may all be dead. A caller's
callback cannot break the connection it is reporting on.
The tests run the shipped `connectToGroup`, lifted out as text, against a fake
clock — a real one would make each scenario a minute and blur the only thing
worth asserting, which is when the deadline fires. Dead node: 10 s. Slow but
moving: connects at 20 s where it used to fail at 10. Answers then stops: 18 s.
Progress that never finishes: the 30 s ceiling. Two dead nodes: two windows, both
transports closed. Checked against the flat deadline, which fails three of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
`{ ..._auth }` after the await spreads a null _auth to {}, so the renewal
stored a token with no username and no userId. The app renders the
signed-in interface from that and throws on user.username[0] — a blank
page on every load, in localStorage, until the site's data is cleared.
The sign-out wins the race now, and loadAuth treats an identity-less
object as signed out so a browser already holding one heals itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
boot-guard.js is a classic script loaded before the module graph, so it
survives the graph failing to link. If #app is still empty after ten
seconds it names what failed and offers a reset of this origin — cache,
storage, databases and the service worker, which clearing the cache does
not touch.
Two real defects found building it: openDB never settled when an upgrade
was blocked by another tab, and a connection it gave up on stayed open
and squatted the database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The ceiling is 1200 tracks on a library that repeats and 660 on one that
never does; the message said 1000, which would send a reader back into the
wall at 660. It says 500, measured at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
A DataChannel send() throws above the max-message-size the node advertises
(aiortc: 65536), so the 1 MB body cap was unreachable and each body push
swallowed the difference in a bare catch. Cap at 62 KB after sealing, name
the playlist in the sync result, keep syncing the rest.
Measured: 1000 tracks seal to 53 276 bytes, so the ceiling is ~1200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
`max-height: calc(100vh - 16px)` says how tall the menu may be and nothing
about where its bottom lands, so one opened partway down the window ran
past it and its last rows scrolled out of reach. Measure from where the
panel was placed, and re-measure when a submenu opens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The dismiss-on-scroll listener is on the capture phase, because `scroll`
does not bubble — so it also heard the menu scrolling itself, and a long
tracklist closed the moment it was wheeled. Filter on the event's origin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from a phone: signing in with the same account showed no
playlists. syncWith was called from exactly one place in the interface,
so creating a playlist, deleting one, removing a track and saving the
queue all wrote to IndexedDB and stopped there. The store pushes itself
now, coalesced, so a new mutation cannot forget to.
Silence was the real defect. The node audited only successes, so a
refusal left no trace and user_blob_list none at all; the background
push swallowed its reason; the interface said nothing. All three report
now, and "Sync now" says what happened either way.
An unreadable blob on a node was treated as a fetch failure and returned
before the push — permanent, once the node held anything. It is an
absence: the client is the authority, and it gets overwritten.
A sign-in reconciles whatever this browser already holds, a pending push
is flushed when the page goes away, and a push that did not land is
retried once.
no_key is spelled out: a client that signs in with its remembered device
key only ever has a bundle key persisted before the playlist subkey
existed, and an AES handle is non-extractable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An artist with one album got a heading and one cover on a row that fits
five, and a library is mostly single-album artists. Consecutive singles
share one grid, in place, so the page stays in artist order.
Each pooled cover keeps its artist's name above it in the same type as
a section heading. Dropping it was the first version and it was wrong:
scrolling then alternates between artists written large and small.
Measured on the probe's fixture: 4208px to 1895px, and a walk of the
page reaches all 21 covers instead of 9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
One button in Music's sticky toolbar — load, create, delete, remove a
track, sync now — and "add to playlist" on every cover and row. Both
surfaces share one list, read from the manifest, so they open instantly
with every node offline and no body is fetched until one is wanted.
Submenus expand in place rather than flying out: the account menu's
language list already does this, and a flyout has nowhere to go at 400px.
The tracklist under "remove a track" loads when it is expanded.
A name is typed into a field. Electron has no prompt — it throws.
Also splits the two playback failures: a decode failure belongs to that
file and keeps the bounded counter, a connection failure belongs to the
group and skips all of its queued tracks at once. Six dead tracks are
one more than the bound, which is where a playlist would otherwise stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
playlists.js is IndexedDB, WebCrypto and a transport, and node has no
IndexedDB — so it is driven in Chrome against a node stubbed to record
what it was handed, which is also how what leaves the browser is
checked to be sealed.
Sync asks the node what it holds (user_blob_list) rather than comparing
against the merged watermark, which says nothing about that node: the
first version pushed every body on every sync. A tombstoned playlist's
body is deleted as each node is reached, or the quota fills with graves.
The database version and its stores stay in hub-client.js — two modules
opening one database at versions of their own is a VersionError thrown
at whichever runs second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The player's queue could only be replaced: every onPlayQueue reset
tracks/order/pos together. It becomes one reducer (queue-ops.js) with
an `op`, because two appends batched into one tick cannot both read the
track count out of separate useStates.
A shared pop-up menu (menu.js) carries the three verbs, on right-click
and on a dots button. A track row is now a div holding two buttons: a
button cannot contain a button.
Found by the browser probe: both music wrappers took two arguments and
forwarded two, so every "add to queue" arrived as a plain play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
Previous/next arrows in the pinned toolbar, on group pages and in Search.
Page size is an account preference (Settings → Defaults), 50 by default,
10 to 200 in steps of 10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
load
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
| |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`terminate()` signals the parent only. Chrome's zygote, renderer and gpu
children outlive it by a moment and go on writing into the profile, so rmtree
walked a directory that gained a file between its readdir and its rmdir and
raised "Directory not empty". The probe exited non-zero, and every test in the
file errored at setup — intermittently, roughly one run in three, for a reason
nowhere near the chat code they were testing.
TemporaryDirectory(ignore_cleanup_errors=True) in all four probes that own a
profile: a few bytes left in a throwaway directory are harmless, failing the run
is not. `proc.wait()` after `kill()` was also missing — a killed process still
has to be reaped.
layout_probe.py never cleaned up at all (mkdtemp, no removal) and never waited
for Chrome; it leaked a profile into /tmp on every run.
Ten consecutive runs of test_chat_send.py are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |\
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Downloads have been encrypted under a GEK-derived key since the beginning:
`file_chunk` and `stream_data` both go through `chunk_ciphertext`. Uploads
never were. `file_upload` carried the filename and the raw bytes in plain
msgpack, and `file_upload_ack` carried the name the node stored them under —
so the same file was ciphertext leaving a node and plaintext arriving at one.
There was no threat model behind that asymmetry.
Both halves now travel sealed under a third groupbox purpose,
HKDF(GEK, info="meshbay:upload:v1"). The filename, the destination folder and
the bytes are all inside the seal; only `upload_id` and `chunk_index` stay in
clear, because the node routes and orders on them before it can decrypt. This
direction seals *towards* the node — it holds the GEK for its own group — and
it opens the payload before it picks a destination or touches the disk.
What that forced, and why none of it is optional:
- `filename` was the correlation key on both sides. It cannot be: matching an
ack to its request by name would hand back exactly what the seal hides.
`upload_id` replaces it — client-drawn, opaque to the node, unique within a
connection, never an authorization input. The property it guarded (one
refusal fails one upload, not every upload in flight) is unchanged.
- Refusals can no longer quote what they refused. `No directory named 'X'`
becomes `No such directory in this group` plus the `code` that was already
there; the client knows what it sent.
- No plaintext fallback. A path that still accepts plaintext is not a sealed
path, so an unsealed `file_upload` is refused with `upload_not_sealed`.
Hardened while here, because what comes out of a seal is authenticated but not
validated — a member can seal anything: `filename` and `data` have their types
checked before any upload state is created, and `chunk_index`/`total_chunks`,
which are outside the seal by necessity, can no longer raise where a refusal
was meant.
Tests. `test_upload_sealed.py` pins the node half: nothing identifying on the
wire, tamper/wrong-key/wrong-group all refused with nothing written, and
multi-chunk reassembly unchanged. `test_upload_seal_client.py` drives the
shipped `uploadFile` over the shipped `crypto.js` under node and feeds its real
frames to the real `_do_file_upload` — the file lands intact, and the ack the
node actually produced comes back with the name it chose for a collision, which
is the half a source-reading test cannot see. Both upload purposes join the
JS/Python groupbox parity vectors.
BREAKING CHANGE: MNP 2.0. `file_upload`/`file_upload_ack` change shape on the
wire every deployed client speaks, which is MAJOR by the same rule 1.0 was —
but the break is confined to uploads. `MNP_MIN_SUPPORTED` stays at "1.0", so a
1.x peer still connects, browses, downloads, streams and chats; only its
uploads are refused, with a message saying which side is old. The client checks
the node's version before sending a chunk, so neither side meets this as a
timeout. This is the version negotiation shipped in 1.0 earning its keep: 1.0
cost a flag day, 2.0 costs a refusal code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
MNP carried no correlation id. A reply named its own type and nothing
else, so a client with more than one request in flight worked out which
one a message answered from the message itself — and for the replies
that name nothing it could not. `_dispatch` fell through to matching by
arrival order, which is a guess. `_sendAndWait` had the right value all
along: it keys `_pending` by `this._seqId++` and never put it on the
wire.
The guess fails asymmetrically, which is why it hid. The victim is not
the request that was answered wrongly — it is the unrelated one that now
waits out its own 30s timeout for a reply already delivered elsewhere.
Live on 2026-09-06: five `music_meta_req` sat pending for over 100
seconds behind a failing MusicBrainz, and a `device_list_result` was
handed to one of them. The composer is disabled while a send is in
flight, so a chat message whose reply went astray the same way left the
Chat tab looking frozen for thirty seconds, then unfroze on its own.
The `ack` half of this was fixed on 2026-08-30 by matching on request
type. That closed the instance and left the class open: a refusal has no
type to match on either, and `_dispatch_message`'s catch-all answers
every unforeseen failure with `{"type": "error", "detail": "Request
failed"}` — 238 of this module's 240 error sends name nothing at all.
`req_id` now rides on the request and comes back on the reply. On the
node it is published for the whole handler in a ContextVar and stamped
by `_send`: a parameter would have meant threading an argument through
all 240 send sites, and asyncio copies the context into a task, so a
handler that `_spawn`s its real work still answers under the right id.
It is never stamped on a broadcast — those answer nothing, and the
owner check in `_send` is what keeps a chat broadcast or an index push
from reaching another peer looking like a reply.
On the client, `_dispatch` resolves on `req_id` first and the
arrival-order fallback is gone the moment a node proves it stamps
(`_correlates`, armed by the handshake's own reply). The fallback stays
for an MNP 1.0 node, unchanged and no wider: there it is the only thing
there is, and removing it would leave device_list_result, join_result
and the handshake replies reaching nobody.
Two things fall out. `sendChat` refuses an `error` reply like every
other request in the file — it returned it as success, which did not
matter while a refusal reached the wrong caller anyway and would now
show a rejected message as sent. And `_group_ctx` uses `.get`: a reload
pops a removed group while sessions connected to it are open, and every
request they had left raised KeyError into that same catch-all.
Sealed index messages are the one exception to the fast path. They
cannot be handed over until they are opened, which is asynchronous while
`_dispatch` is not — resolving on the id alone gave `fetchIndex` the
envelope and skipped `onIndexSync` entirely. Caught by extending
`index_seal_probe.mjs` to stamp a reply the way a current node does,
after the hub suite passed over it: the probe built its own frames and
had never seen one.
Tests, all failing before and passing after: `test_chat_send.py` drives
the real ChatPanel over the real transport for both shapes of reply with
an older request pending (3 of its 6 are new, and the 3 for `ack` pass
either way, so it discriminates); `test_reply_correlation.py` pins the
node's half — the refusals that name nothing else, the broadcast that
must not be stamped, and a late reply from a spawned task answering
under its own id rather than the most recent request's.
Full suite: 1897 passed, same 11 pre-existing failures as before.
QUIC keeps its own dispatch and is not stamped. It is disabled by
default and no browser request reaches it, but the asymmetry is real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dn1xYx9uT69mCB6UDvyKAN
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Chat messages are sealed with AES-256-GCM under a key derived per group, per
epoch, per *device*, and signed over the ciphertext with the device key the
node pinned. The node relays and archives; it cannot read a message.
There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a
1.x peer is refused at the handshake with `version_too_old` rather than
admitted and then unable to speak. An opt-in flag was designed and rejected:
every node is a test node, so it would have bought nothing and left a plaintext
branch reachable — C6's lesson one feature later. A test reads the source and
refuses any code that consults a `chat_encrypted` setting.
Not Sender Keys, and `senderkeys.py` is now documented as unused. With
distribution under the group key and a node that serves history to devices
which were not present, the node must retain each chain's earliest key, and a
chain key at iteration i yields every message key from i on by pure HKDF —
forward secrecy is zero either way. What the ratchet was left buying was
stateful client code with silent failure modes, three of them reproduced: any
member could sign as any other, a second device dropped the first's chain, and
the skipped-key cache grew without bound. The reasoning is in
docs/chat-sender-keys.md, which is the specification and the decision record.
Epochs, not rotation: the epoch key is wrapped under the group key at delivery
and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived
archive key would have made every message ever sent unreadable on the first
`member unpin`, which is the documented step after removing a member. A new
epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs
are kept and still delivered, so history stays readable to everyone who could
already read it, and nothing anywhere deletes one.
Three prerequisites this needed, each a live defect on its own:
* The peer registry was keyed by user_id, so one account's second device
evicted the first and the broadcast skipped recipients by account — a
person's phone never saw what they typed on their laptop.
* The handshake authenticated an account, never a device. `device_hello`
(additive, signed, refused unless the key is a live device of this account in
the node's own roster) is what lets the node refuse a member claiming
somebody else's key.
* `_admin_exec_file_delete` authorized against the exact uploading key, so
device linking had already broken deleting your own file from your other
device. It now authorizes against any non-revoked device of `uploader_id`.
Found by driving the real panel over the real transport, not by reading source:
`chat_keys_resp` was routed by arrival order and handed to an unanswered
`media_meta_req` — the original frozen-tab defect in a message type that did
not exist when that probe was written. And `_asText` had been deleted with an
unrelated helper beside it; its only caller sits inside a promise the panel
catches, so every conversation rendered empty with nothing in the console.
Existing node data is migrated by QE/migration/migrate_chat_encryption.py
(not versioned, per the QE rule), run with the node stopped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel
sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by
`sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the
ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and
authenticate before it would trust a decryption. Verify, then decrypt.
The ack line is integrity, not confidentiality: the signed handshake transcript
names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the
rest were authenticated by the DTLS channel alone. The index line is defence in
depth against a repeat of C1/C6 — a peer served before the handshake completes
now gets ciphertext, not filenames. Nothing against an observer, the hub, or a
member; that is the whole claim. `index_progress` stays clear (D3, counters
only). Chat is out of scope.
Failure is fatal: a payload that does not open ends the session naming the
message type — never an empty index or an empty `enabled_apps`, both of which
are legitimate states.
Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min`
on `handshake` and `handshake_challenge`, refused with `version_too_old` /
`version_too_new` / `version_unreadable`. The flag day was already being paid
for; the next breaking change now costs a refusal message.
BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and
every node must deploy together; the SPA is served by the hub, so a browser
picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Typing a message froze the Chat tab: the composer stopped taking clicks and
keystrokes, the message never appeared, and it was there all along on the next
visit to the tab.
The node answers a chat message with a bare {"type": "ack"} -- no request id,
no type of its own -- so _dispatch had nothing to match it on and left it to
the arrival-order guess at the end of the function. That guess is wrong the
moment anything else this browser asked for is still waiting: the ack went to
*that* request, and the chat send waited out _sendAndWait's own 30s timeout.
Since the composer is disabled while a send is in flight, that reads as a
frozen tab; the node had stored the message and answered, into somebody else's
promise.
An outstanding request is the ordinary case, not a rare one. The node refuses
an unknown file_id with a bare `error`, which names no request either and so
reaches none, leaving the Videos tab's media_meta_req in _pending for the full
30s. That is the one that was live when this was found.
- `ack` is now matched by request type: chat_msg, or the keypair-bundle store
and delete, which name themselves in `detail`. A node naming neither still
has its reply placed rather than dropped.
Every line of chat-app.js is correct and every routed message in transport.js
is routed correctly -- the defect is in the seam, so tests/harness/
chat_send_probe.py drives the two together: the real ChatPanel over the real
MeshBayTransport, with only the DataChannel replaced by a stand-in answering
what the node answers. test_chat_send.py asserts against it, and with the fix
reverted all three of its tests fail on the three visible halves of the defect
-- the composer still disabled, the message absent, and the ack resolving the
unrelated request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFF4BL8VSKrghkSLzCrTVs
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A group could open on a tab that rendered nothing: no panel, no tab shown
active, and nothing on screen to explain it.
The landing tab is chosen at mount from a preference -- default_tab for the
group, else the account-wide one, else 'chat'. Which applications the group
runs comes from the node, in the handshake ack, several awaits later. A
preference is a preference, not a promise that the app exists here, so the
two disagree in two ordinary cases: the group has Chat disabled while
'chat' is everyone's default, or the reader prefers an app this group does
not run. `apps.map(a => tab === a.key && ...)` then matches nothing.
The first app the group does offer answers both. Two more cases come free:
a preference naming an app that no longer exists, and an operator disabling
the app someone is currently looking at -- enabledApps changes live over
apps_enabled, and being moved to a working tab beats staring at an empty
panel. Settings is exempt: it is not an application, and the create-group
wizard lands on it deliberately.
`const apps` moves above the effect that reads it; a const further down
would be in its temporal dead zone, which is the hook-ordering trap already
recorded in CLAUDE.md.
tests/harness/group_tab_probe.py renders the real GroupPage against a stub
node answering a chosen enabled_apps and reads the tab bar back, over five
cases. With the fix reverted the three fallback cases report no active tab
at all and four of the six tests fail; the two that pass either way are the
ones that must not change -- a group running everything, and a preference
the group does honour (Videos stays selected, so the fallback has not
become "always the first app").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The chat could not be read back: any wheel gesture was undone in the frame
it happened in, and the "jump to latest" button never appeared.
None of the pins in ChatPanel are at fault -- every one of them is guarded
by "only if the reader is at the bottom". The reader never got to stop
being at the bottom.
fit() set the panel's height, read documentElement.scrollHeight back and
subtracted the overflow, so the document alternately did and did not
overflow the window. The page scrollbar appeared and vanished with it and
visualViewport fired resize at every pass -- the event fit() is bound to.
It therefore re-entered itself for the life of the panel: measured at 240
firings in two seconds on a page nobody was touching, against 2 for a bare
document. Each pass ran fitAndPin, which re-pinned the list to the bottom
before the scroll event that would have recorded the gesture was delivered
a frame later, so atBottomRef never went false.
- fit() learns the space below the panel once and remembers it on the
element instead of re-deriving it by writing and measuring back. At the
steady state it writes nothing, so it produces no resize. A real window
resize or an orientation change forgets the learnt value and measures
again (the page under the panel may have reflowed); visualViewport
deliberately does not, since a phone fires it constantly.
- The scroll-to-bottom is now scoped to *arrival*, which is all it was ever
for: opening the group, or coming back to the Chat tab, including the
thumbnails and link-preview cards that keep growing the list for a second
afterwards. It ends when the reader takes hold of the scroll, and the
ResizeObserver disconnects there.
- That release is recorded from the gesture (wheel/touchmove/pointerdown/
keydown), not from the scroll event, which arrives too late to protect
anything.
Unchanged: landing on the newest message, following new messages while
already at the bottom, the "load older" anchor and the unread marker.
tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser
and reads a conversation back; test_chat_scroll_up.py asserts against it.
With the fix reverted, five of its six tests fail and the sixth -- landing
on the newest message -- still passes, which is the property that must not
have been traded away. A structural test cannot see any of this, which is
why it is measured.
test_layout_responsive.py pinned the listener's name and follows the
rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
- test_transport_contracts: CreateGroupPage was refactored into a
routing wrapper; assertions now read CreateGroupFormSimple
- test_task_lifetime: _spawn now uses an _on_done wrapper instead of
a bare self._tasks.discard callback; assertion checks both parts
- test_video_buffer_ceiling: target the real updateend handler, not
the settled() utility; add awaitingInitRef to the MSE harness scope
- test_video_seek: silence debug console.log in window_leak harness
so it does not pollute the JSON output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**The chat tab was 8px too tall, at every window size.** The panel is sized from
JS to `viewport - top - 16`, which puts its bottom 16px above the fold — but it
sits inside `.main`, which adds 24px of padding below it. Eight pixels of
document past the window, whatever the window. Measured at 700, 900 and 1200:
`scrollHeight` 708, 908, 1208.
This is the second one of these — the sign-in card was `.page-center` and
`.layout` each reserving `100vh - 52px` — so it is now measured in the suite
rather than reasoned about. `tests/harness/scroll_probe.py` renders the real
markup against the real stylesheet and **runs the real `fit()` lifted out of
`app.js`**: a copy of the formula in a test would go on passing after the
original changed, which is exactly the bug being guarded.
The fix does not encode 24 anywhere. The first pass runs as before, then the
leftover is measured and taken off, so anything added below the panel later is
absorbed the same way. Now `scrollHeight == innerHeight` at all three heights,
nothing below the fold, and the panel still fills the room it has — that last
one has its own test, because shrinking the chat to 240px would satisfy every
other assertion here and be useless.
The Settings tab was measured too and is **not** a bug: it fits at 1200px and
overflows only when its content is genuinely taller than the window.
**Group creation asked one question twice.** Visibility and admission were
separate selectors that could only ever be set together — picking Public reached
over and set the policy — and two of the four combinations are meaningless. The
API already refused public+invite with a 422, so the form could build a request
that could not succeed. Private+open was accepted and should not have been: a
group anyone may join that nobody can find is a listing with the listing
removed, since joining goes through the node and there is no link to pass
around.
So: one selector, "who can join", and the request derives the rest. The API now
refuses the other impossible pair as well, with a message that says which way to
resolve it. Six locale strings the visibility box owned are deleted rather than
left unread in ten files, and the two surviving descriptions now say what each
choice means for who can *find* the group — with the word "public" gone from the
page, nothing else would have said it, and someone would publish a group without
meaning to.
865 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Electron 42 / Chromium 148, launched under xvfb. The packaged interface mounts
over `app://` with a secure context, `crypto.subtle` present, Argon2 WASM
loaded, and no console errors. Three statements in the design were wrong, and
only launching it found them.
**A CSP in a `<meta>` tag silently drops `frame-ancestors`.** Chromium says so
in the console. A policy carrying a directive that does nothing is worse than
one without it, so the policy is sent as a header by the protocol handler —
which is also the only thing serving the interface, so one source instead of
two.
**`secure: true` is not what makes the service worker register.** Chromium
refuses a worker on a custom scheme whatever its privileges: "The URL protocol
of the current origin ('app://meshbay') is not supported". The application has
no service worker and needs none — it saves through a native dialog, which is
the better of the two paths. `sw.js` stays in the package because the same files
serve the browser, where it is one of only three ways to write a large file.
What `secure: true` is actually for was measured at the same time: without it
**the whole of `crypto.subtle` is undefined**. The first probe loaded a `data:`
URL and every algorithm failed with TypeError, AES-GCM included — which is why
the probe was rewritten before believing its answer. X25519 and Ed25519 are both
present on Chromium 148, settling the version floor left open as O6.
**The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS
refuses it. The hub has *no CORS middleware at all* — its API is reachable from
no web origin whatever — and that is worth keeping. Widening it for
`app://meshbay` would be worse than it looks: that origin is not a credential,
since any Electron application can claim the same scheme and host name.
So every hub call leaves from the main process, exactly as saving a file does,
and it refuses any origin that is not the hub the user signed in to.
`platform.apiFetch()` is `fetch` in a browser and the bridge in the application,
so no caller has to know which it got. `transport.js` reaches it through a
global because it is a classic script, not a module — the alternative was a
second fetch path, which is how two callers of one hub start disagreeing about
how to reach it.
Verified from inside Electron: the main process gets 200 from
/v1/hub/version, the renderer is refused by CORS, and **a script served by the
hub is refused by the policy** — T3's mitigation demonstrated rather than
asserted.
Build note, written into the README because it will bite the next person:
**Ubuntu 24.04's nodejs 18 cannot install Electron at all** — the download
script `require()`s an ESM module, which Node gained in 22. Node 24 LTS,
checksum-verified against nodejs.org, is what this was built with.
package-lock.json is committed; builds use `npm ci`, not `npm install`.
799 tests pass, e2e.py still passes end to end. The session harness needed a
platform stub: it lifts `hubFetch` out of app.js as text and runs it, so the
adapter is now part of the environment it models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|