<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/tests, branch 0.5</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.5</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.5'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-17T15:38:00Z</updated>
<entry>
<title>fix(hub): a session that renews itself</title>
<updated>2026-08-17T15:38:00Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T15:38:00Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6562665c80f96f30fef95af83a0abcf71f41795f'/>
<id>urn:sha1:6562665c80f96f30fef95af83a0abcf71f41795f</id>
<content type='text'>
Reported: after an hour of watching a film, every action answers "token expired
or invalid", with signing out and back in as the only way on. Reopening the tab
the next day did the same.

The access token lasts an hour and the refresh token thirty days, and nothing
used the second one. `hubFetch` reported a 401 like any other error, and
watching a film is precisely an hour in which the hub hears nothing at all,
because the video travels over WebRTC. So the token aged out with no request to
notice, and a tab reopened the next morning presented a stale token with a
perfectly good refresh token sitting beside it in localStorage.

Underneath was the reason it could not be recovered from. The hub *rotates*:
the refresh endpoint revokes the token presented, returns a replacement, and
treats a revoked one presented again as theft, revoking the whole family. The
client kept only the access token out of that response. So the refresh token was
spent on first use and the second attempt did not merely fail — it destroyed the
family. Which is exactly the reported symptom.

Renewal now happens on a margin, on returning to the tab, on mount, and on a 401
with the request replayed. Concurrent renewals share one request: two 401s
racing would otherwise present the same refresh token twice, and the hub cannot
tell that from theft, so the remedy would have been worse than the fault. A
refusal signs out cleanly rather than leaving a session that fails every call
while looking signed in.

The lifetime goes to four hours, which is not what makes long sessions work —
renewal is — but is what someone has to notice by if renewal itself breaks. An
hour was less than a feature film. Twelve was considered and declined: it widens
the window in which a leaked token cannot be turned off, and it lets the renewal
path go a whole day between uses, which is how it came to be broken here without
anyone noticing. Production sets this in its own hub.toml, so both moved.

The tests run the shipped code against a hub that enforces rotation, because a
stub that accepted the same refresh token twice would have passed against the
broken client. Checked that dropping the rotated token reproduces the revoked
family, so the guard is guarding something.

Also widens the orphan-setter rule to ignore `setX` functions declared in the
module: `setAuth` is not a hook setter, and a rule that cries wolf is one
somebody eventually silences. Verified it still catches a real orphan.
</content>
</entry>
<entry>
<title>fix(hub): the transfers panel hung off the side of a phone</title>
<updated>2026-08-17T07:28:49Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T07:28:49Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=af30a10b83366416c25eaacec0b4df77526d0924'/>
<id>urn:sha1:af30a10b83366416c25eaacec0b4df77526d0924</id>
<content type='text'>
Reported: on mobile you see only the right-hand edge of the panel, without the
content. Measured, before anything was changed:

    320 px viewport -&gt; panel at -138..192, 138 px off the left
    360 px          -&gt;  -98..232
    412 px          -&gt;  -46..284

The panel is 330 px wide and anchored to the right edge of its button — but
that button is not at the right edge of the screen, since the bell and the user
menu come after it. What falls off is the left-hand side, which is where the
file names are, so what stayed on screen was a strip of progress bars belonging
to nothing.

Narrowing it would not have helped: the overflow comes from where the right
edge is pinned, not from the width. Below the existing 768 px breakpoint the
panel is anchored to the viewport instead, full width on a phone and capped at
420 px on a tablet, where stretching two filenames across 750 px would be
silly. Desktop keeps its 330 px against the button.

The interesting part is how it was found. The responsive tests read numbers out
of the stylesheet and said, in their own docstring, that a layout could not be
measured because the suite had no browser. It has one now — Chrome, from the
video work — so layout_probe.py renders the real stylesheet at a given width and
returns rectangles. `width: 330px` was never the thing worth asserting on.

An iframe carries the viewport, because a headless window will not go below
about 500 px, and one browser measures every width: launching one per test put
three minutes on the suite against twenty-six seconds for all of them. Checked
that the new tests fail with the rule removed — three of them do — and that
they pass with it back.
</content>
</entry>
<entry>
<title>test(hub): a hook that depends on one declared below it never runs</title>
<updated>2026-08-17T00:16:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T00:16:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=42047dac4041e72e09499e3adf145f1c0f83b284'/>
<id>urn:sha1:42047dac4041e72e09499e3adf145f1c0f83b284</id>
<content type='text'>
`const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b`
further down the component is still in its temporal dead zone. ReferenceError on
every render, before anything the component does can run — and the symptom is
the component simply not appearing. Clicking a video did nothing at all: no
picture, no error on screen, nothing in the node's log because nothing was ever
requested. It reached production.

Nothing caught it. `node --check` passes, the code is well-formed. Worse, the
MSE harness extracts the player functions into an order of its own and therefore
*reordered* them before running — quietly repairing the one class of defect it
was best placed to catch. It sorts by position in the file now, and
test_hook_ordering.py checks the property directly across the whole SPA. Both
the rule and the harness are checked against the layout that actually shipped.

test_video_seek.py covers the rest of seeking, and window_leak.mjs forces the
race that made the third seek hang: the whole in-flight window arriving while
`reinitAt` is still awaiting. Before, the player is left believing eight
segments are in flight and grants nothing; after, the window comes back. A run
that happens to work proves nothing about a race, which is the point of forcing
the worst case rather than trusting a longer session.
</content>
</entry>
<entry>
<title>fix(hub): serve the SPA under a fingerprint of what it is</title>
<updated>2026-08-16T18:57:52Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:57:52Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=5dea19d9950518887be7eb14696f600ee023dbbd'/>
<id>urn:sha1:5dea19d9950518887be7eb14696f600ee023dbbd</id>
<content type='text'>
`Cache-Control: no-cache` requires a browser to revalidate, but it only binds
one that asks. A browser that cached app.js before that header existed applies
heuristic freshness instead — a fraction of the file's age, which for a file
dated weeks ago is days — and never asks. It then runs an old player against a
new node.

That cost most of a session. A phone kept a player without the read-ahead bound
and filled the browser's buffer ceiling at 106 MB, the exact symptom the bound
had been written to remove, for an hour after the bounded player went live. A
fix that is written, tested, deployed and served, and still not what runs, is
indistinguishable from a fix that does not work.

The whole module graph now lives under `/a/&lt;content-hash&gt;/`. A path prefix
rather than a query string, because relative imports inherit it: `app.js`
reaching for `./i18n.js` gets the build it was written against, and never a
mixture of two — which does not render a stale page, it fails to link. The URL
changes with the content, so those may be cached hard.

`sw.js` stays at the root. Its scope is its own path, and under the prefix it
would no longer control the pages whose downloads it exists to intercept.
</content>
</entry>
<entry>
<title>fix(hub): bound the video read-ahead by the playhead, not by the network</title>
<updated>2026-08-16T18:57:41Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:57:41Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=373f83236816aaff536dfa2f9589801acac44012'/>
<id>urn:sha1:373f83236816aaff536dfa2f9589801acac44012</id>
<content type='text'>
A 500 MB film loaded about 100 MB and hung on "buffering" for good. 100 MB is
not a number in our code: it is where the browser stops. ffmpeg remuxes with
`-c copy`, so the bytes on the wire are the file's own, and credit granted per
append meant taking them as fast as the network allowed — which for a film is
very much faster than watching it. The SourceBuffer ceiling arrived in the
first minute.

Past it every append was refused, and the refusal was unrecoverable: a refused
append fires no `updateend`, `updateend` was where credit was granted, so the
node sent nothing and no segment arrived to retry the append. Every wakeup the
pipeline had was downstream of the append that had just failed. Playback
continuing — the one thing that frees room — woke nothing at all.

Credit now follows the buffer instead of the writes. `pump()` is the only
place it is granted, it keeps `STREAM_WINDOW` segments in flight while less
than `BUFFER_AHEAD_S` of film is held past the playhead, and it is driven by a
one-second clock and by playback progress, never by arriving data. Buffering
by time makes a two-hour film cost what a two-minute clip costs.

A window rather than a debt, and this took a second measurement to get right:
accumulating a credit per append and releasing the balance when the buffer
finally drained sent six megabytes in one burst, overshot by a minute of film,
then said nothing for forty-six seconds. Measured in Chrome against real
fragmented MP4.

Two smaller things found on the way. `updateend` fires for `remove()` as well
as `appendBuffer()`, so crediting from it paid the node for the player's own
evictions. And a viewer that is deliberately far enough ahead grants nothing
for minutes, which the node read as a closed tab — it now sends `stream_more`
with n=0, which grants no room but proves someone is there.

The first version of the test modelled the credit loop and passed while the
player still hung: a model written by whoever wrote the fix agrees with it by
construction. `tests/harness/mse_harness.mjs` lifts the real functions out of
app.js as text and runs them against a SourceBuffer that has a ceiling. What is
modelled is the browser.
</content>
</entry>
<entry>
<title>feat(hub): chat, presence, a Profile page, and downloads that do not freeze</title>
<updated>2026-08-16T13:29:11Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:29:11Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=84e778d5cdd1bb5cded8a7c0238797c17d48666c'/>
<id>urn:sha1:84e778d5cdd1bb5cded8a7c0238797c17d48666c</id>
<content type='text'>
Chat opens on the newest hundred messages, loads fifty older on demand with the
reading position anchored — the distance from the *bottom*, since everything
above the viewport just grew — and follows new messages only when the reader was
already at the end. Day separators, sender grouping, an unread marker, and a
jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes
Preact reuse the wrong bubbles.

A presence dot per group in the sidebar, three states, each backed by something:
the hub's registry, or a connection this browser made or failed to make. Never
colour alone — red and green are the pair colour-blind readers cannot separate —
so each dot carries a title and an aria-label.

Profile is split out of Settings: identity, node link, pinned node identities and
account deletion. Mixing them put an irreversible button two scrolls under a
theme picker. The create-group page loses its centred 520 px card, which left
190 px of margin either side, and its two button panels become a radio group —
a button conveys no chosen state to a screen reader, and side by side they read
as two independent actions rather than one either/or.

The Files toolbar shows its actions as icon buttons the moment Select is on,
disabled when they do not apply rather than appearing and vanishing. On a phone
the right-hand group could not wrap and ran 130 px off the screen.

Streamed downloads no longer freeze after one chunk. `registration.active` says
a worker exists, not that this page is controlled by it — and an uncontrolled
page's requests never reach its fetch handler, so the worker took the stream and
was never asked for it, leaving `writer.write()` waiting on backpressure that
would never lift. The page now requires control and the worker confirms it
actually served the request before the sink is trusted.

Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw
on every Files action; the chat scrollbar stopped short of the bottom; the
owner's row sat lower than the rest; About showed a version hardcoded two
releases ago.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(hub): leaving a group, a cap on public ones, and hosting as a precondition</title>
<updated>2026-08-16T13:28:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:28:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0bd3f805ffbd04b40b5150474336a5e5d200e72b'/>
<id>urn:sha1:0bd3f805ffbd04b40b5150474336a5e5d200e72b</id>
<content type='text'>
Leaving is its own endpoint rather than a relaxation of the owner's removal
check — an authorization rule with an exception in it is the one that gets read
wrong later. The owner cannot leave: the group would be left with nobody able
to admit, edit or delete it, which is the answer removal and account deletion
already give.

Public groups are capped at ten live ones per owner. They are the ones that
cost other people something — listed in Discover, joinable by anyone — so a
script that opens hundreds fills the directory for everybody. Private groups
are invisible to non-members and are not capped. Hub staff are exempt; the cap
is anti-spam, not a rule about running an instance. Creation is the only place
it can be checked, and deliberately so, because PATCH refuses to change
visibility at all.

A group is now listed only once a node has announced that it hosts it. Before
that it has no files, no key and nothing to connect to, so showing it to a
member produces a name they cannot open and cannot be told why; its owner still
sees it while they set the node up. `meshbay-hub prune-groups` collects the
ones that never got a node, meant for cron, with --dry-run. The migration
backfills hosted_at from created_at: without that the first run would have
deleted every live group.

Presence rides on the group list itself, read from the signaling registry the
hub already keeps — no poll, no timer. It says a node is connected *to the hub*,
which is not a promise that this browser can reach it and not something a
dishonest hub could not fake; the client downgrades it on a connection it tried
and failed, which is the evidence that concerns the reader.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(hub): translate the web client into nine more languages</title>
<updated>2026-08-15T22:21:07Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T22:21:07Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3b2d03a919e56d14f8097844b97b844ddf526cd6'/>
<id>urn:sha1:3b2d03a919e56d14f8097844b97b844ddf526cd6</id>
<content type='text'>
French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese,
German, Italian, Dutch and Polish, all in the formal register. `hub`,
`node` and `GEK` stay in English: they name the CLI, node.toml and the
docs, and translating them would cut the interface off from everything an
operator reads and types.

Catalogues move out of i18n.js into locales/, one file per language,
fetched with a dynamic import. A visitor downloads their language plus
English as a fallback — about 36 KB rather than the ~180 KB that ten
inlined catalogues would have cost everyone. i18n.js keeps only the
loader, so the first render now waits for initLocale().

Three things the old code got wrong, none of them visible until there was
a second language:

- Resolution trimmed a tag to its base before matching, so a browser
  reporting pt-BR looked for a `pt` catalogue that does not exist and fell
  back to English. Matching is now exact first, then by base language.
- Counted strings were single strings, so Polish could not express
  1 plik / 2 pliki / 5 plików at all. t() selects through
  Intl.PluralRules; en.js gains the same treatment, which incidentally
  fixes "1 files".
- Interpolation used String.replace, which reads `$&amp;` in the replacement.
  A file named rap$&amp;sody.mp3 rendered corrupted in its own delete dialog.

Five strings were still hardcoded in app.js — the group name placeholder
and the four visibility/join-policy descriptions — and are now keyed.

test_locales.py holds the nine translations to the shape of en.js: same
keys, a counted string stays counted everywhere, every plural entry covers
each category Intl actually produces for that language, and the
{placeholders} survive translation. Verified failing first, against a
catalogue with a key removed, a placeholder dropped and the Polish `few`
form deleted.

The language menu also grew from one entry to ten, which overran a short
viewport inside a dropdown that clipped instead of scrolling.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: stop a stream on close, count only real users, record where a node is</title>
<updated>2026-08-15T17:01:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T17:01:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=05f4feab641740c944d636f29a03f8c0dd1328c7'/>
<id>urn:sha1:05f4feab641740c944d636f29a03f8c0dd1328c7</id>
<content type='text'>
**Closing the viewer left the node working.** Nothing told it to stop:
the player dropped its handlers, which only made the browser deaf. ffmpeg
kept running and held one of the node's two transcode slots until the
credit timeout expired two minutes later — which is why the next video
answered "server busy". `stream_stop` ends it at once, and the viewer
also drops its queue, ends the MediaSource and revokes the object URL on
the way out, any of which could be holding megabytes of decrypted video.

While there: `file_chunk` replies were matched to their requests by
arrival order, which was true by luck rather than by construction. The
reply now names the file it belongs to and is matched on that and the
chunk index; a chunk nobody is waiting for is dropped instead of being
handed to whatever request happens to be oldest.

**The administration panel counted its own history.** A deleted account
is tombstoned so the connection log stays readable, and every count and
list treated that row as a user — including a group's member count, and
the member list of the group itself. They do not any more.

**Where a node is.** `endpoint_hint` is what a node believes its address
to be, learned from a STUN server and sent to us: useful for reaching it,
and a claim. The announcement that carries it is signed with the node key
over a fresh timestamp, so the address that request *arrives from* is the
address of whoever holds that key — that is now recorded on the node row
and shown in a Nodes tab, next to the hint, with the difference spelled
out. Clients get the same treatment: `webrtc_offer` is logged with the
address the hub saw when a browser starts a peer connection.

Verified against the live deployment: the node's row reads 90.112.206.172
after a restart, and in e2e a stopped stream goes quiet in one message
and the next one starts immediately instead of being refused.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(groups): remove a member, and keep gigabytes out of the tab</title>
<updated>2026-08-15T15:23:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T15:23:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=dd3927a661273734493f65a593755b95aecf5f09'/>
<id>urn:sha1:dd3927a661273734493f65a593755b95aecf5f09</id>
<content type='text'>
**Removing a member.** The owner can do it from the Members tab, and it
is two halves in the order that fails safe: the node stops serving the
group key first (an operator-signed request, so a paired browser only),
then the hub drops the membership row. The other order would leave
someone able to reach a node that still serves them.

It is a membership, not an account. The user row is never written: their
other groups, their files and their pinned identity survive, because one
group's owner must not be able to erase someone from the hub. It is also
per group — a node hosting two loses them from one — and it does not take
back the key they already unwrapped, which is what rotating the GEK is
for. The confirmation and the panel both say so.

**Downloads and streaming through the disk, in both browsers.** The audit
this started as found two ways to put gigabytes in a tab.

Firefox and Safari have no File System Access API, so every download
there was collected in memory. A service worker fixes it: the page keeps
the writable half of a transferred stream, the worker answers a made-up
URL with the readable half and a Content-Disposition header, and the
browser writes it to disk as it arrives, with real backpressure. The
worker caches nothing and falls through on every request that is not one
of these downloads. A zip announces no Content-Length, since the archive
is larger than the files in it and a length we miss truncates the file.

Video was worse and affected both browsers. The node pushed ffmpeg's
whole output as fast as it was produced while the player consumed a
segment at a time, so the queue held the film — and appending all of it
hit the SourceBuffer's cap, where the handler logged the error and
dropped the segment, leaving a hole in the middle of the film with
nothing to show for it. Streaming is credit-based now, 24 segments of
256 KB in flight, verified against the live node: three credits, three
segments, then silence until more are granted. The player evicts what is
more than a minute behind the playhead and retries a refused segment
rather than dropping it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
