<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/static/transport.js, branch 0.14</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.14</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.14'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-14T19:45:53Z</updated>
<entry>
<title>feat: a group can be left out of Search, and Search tries every node</title>
<updated>2026-09-14T19:45:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T19:45:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=cd2745cecff12e894e0dfa702bff6a90f0e8734e'/>
<id>urn:sha1:cd2745cecff12e894e0dfa702bff6a90f0e8734e</id>
<content type='text'>
`search_listed` is a per-group setting on the node, changed by a signed
operator op and carried in the sealed handshake ack. Search reads it after
the handshake and stops there: no index is fetched, cached or merged, in any
of the four views, and the page says how many groups it left out. The switch
is a "Search" section in the group's settings, shown to the operator.

Absent means listed, at every layer: roster default, ack default, and the
client only drops a group on an explicit `false` — so an upgrade or an older
node removes nothing from anyone's Search.

It is a listing preference and protects nothing: the node serves the same
index to Search and to the group page and cannot tell them apart, every
member lists the group by opening it, and a client that ignores the flag
lists it in Search too. Design §9.11 says so, so it is never described as
private. The cost is one handshake per unlisted group, because only the node
knows the setting.

Search also took `nodes[0]` twice — for the index and for the pooled
connection — the defect 4cce50f fixed on the group page only. One
`connectToGroup` now walks the list the same way: a refusal about this
browser stops, `not_hosted` or a failed connection moves on.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
</content>
</entry>
<entry>
<title>feat(ui): an indexing dock above the music bar, on every page</title>
<updated>2026-09-14T09:17:42Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T09:17:42Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2fb66c5baecdd8b49be904f6244f66ba04f68061'/>
<id>urn:sha1:2fb66c5baecdd8b49be904f6244f66ba04f68061</id>
<content type='text'>
Adding a large directory left the operator nothing to look at once they left
the Settings panel that started it, and nothing at all when it was added from
another machine. A band now sits above the music bar on every page: one row
per group with indexing under way, naming the root being walked, percent,
bytes and files, and the roots waiting their turn; "indexing finished" for a
few seconds at the end. A click opens the group's Settings, and × hides the
row until that group is idle.

Two sources feed it. On the node's own machine the desktop client polls the
loopback `GET /api/index-status` for every group, whatever the route. An
operator's group page forwards MNP `index_progress` pushes, resolving the
root from the roots table it opened; an ordinary member keeps the sidebar dot
only, and a page clears its row when it lets go of the group. Where both
describe a group, loopback wins.

Reconcile passes and watchdog bursts show only past 1 GB or 5 s, so a single
dropped file does not flash a bar. The logic lives in index-dock-model.js,
which has no imports and is tested under node. The dock publishes
`--index-dock-h` and the sidebar stops above it and the music bar.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
</content>
</entry>
<entry>
<title>fix: an empty group claim is a claim on nothing</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T07:47:07Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4cce50f09a73739387d5058a6f8183ebac65ae2c'/>
<id>urn:sha1:4cce50f09a73739387d5058a6f8183ebac65ae2c</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
<entry>
<title>refactor(mnp)!: one operation for an app's folders, not one per app</title>
<updated>2026-09-10T15:49:58Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:49:58Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=07ff8b4f6143039fcc74b8cf7c423282bce093c1'/>
<id>urn:sha1:07ff8b4f6143039fcc74b8cf7c423282bce093c1</id>
<content type='text'>
`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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>refactor(spa): stop asking a node what version it is</title>
<updated>2026-09-10T15:29:50Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:29:50Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4753c67816c774323e3ab4efc76d3259e8ded40d'/>
<id>urn:sha1:4753c67816c774323e3ab4efc76d3259e8ded40d</id>
<content type='text'>
`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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>refactor(mnp)!: one answer to "may this member write", and it is the root</title>
<updated>2026-09-10T15:23:40Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:23:40Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1dcedc77083b908b7b3b431bad679a4813884355'/>
<id>urn:sha1:1dcedc77083b908b7b3b431bad679a4813884355</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>test(spa): drive the composer's recovery through the real reconnect</title>
<updated>2026-09-09T14:55:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T14:55:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4d5a07299c792de1251622a41ce7f94870b363fa'/>
<id>urn:sha1:4d5a07299c792de1251622a41ce7f94870b363fa</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
</content>
</entry>
<entry>
<title>fix(spa): a reconnect must give the Chat composer back</title>
<updated>2026-09-09T14:37:23Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T14:37:23Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=391db2f2197b5f7fbdba0c918a24830a5cbe6ee4'/>
<id>urn:sha1:391db2f2197b5f7fbdba0c918a24830a5cbe6ee4</id>
<content type='text'>
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 &amp;&amp; !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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
</content>
</entry>
<entry>
<title>feat: MNP 3.0 — a transfer needs a lease</title>
<updated>2026-09-09T12:00:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T12:00:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac'/>
<id>urn:sha1:53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
</content>
</entry>
<entry>
<title>feat: resume an interrupted upload, and pause one</title>
<updated>2026-09-09T10:01:06Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T10:01:06Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4f5d3d4ac151874f03c6fcc451d6b1d5bb1efb78'/>
<id>urn:sha1:4f5d3d4ac151874f03c6fcc451d6b1d5bb1efb78</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
</content>
</entry>
</feed>
