<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/tests, branch 0.10</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.10</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.10'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-01T12:08:32Z</updated>
<entry>
<title>refactor(node): JSON-only control API, Node page absorbs the admin dashboard</title>
<updated>2026-09-01T12:08:32Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T12:08:32Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=cfc91e0a424163869c64d30e55d55a53f18a3dbf'/>
<id>urn:sha1:cfc91e0a424163869c64d30e55d55a53f18a3dbf</id>
<content type='text'>
Remove the node daemon's server-rendered admin UI (GET / and /audit, the
_render_* helpers and inline templates) and the `meshbay-node ui` CLI verb.
The loopback control API stays; it is now JSON only, ruff-clean, and 453
lines (was 1074). Also drop three never-wired endpoints (/api/config,
/api/chat/history, /ws/chat, plus broadcast_chat) and the pointless
18000/tcp firewall profiles.

The desktop client's Node page (static/node-page.js) takes over what the
dashboard showed, reorganised into six tabs (Overview, Groups, Roster,
Peers, Audit, Settings):
- Overview: version, node id, QUIC port, hub, index-cache maintenance
- Roster: node-wide view with unpin
- Peers and Audit: auto-load on open, no Load button
- Audit: real usernames and group names (resolved from the roster and
  node.toml), Previous/Next pagination newest-first, Export CSV of every
  matching row
- Settings: node settings, STUN, ICE, denylist, then Unlink from hub

Backend: audit.get_entries gains `offset`; /api/audit and /api/peers
resolve ids to names via a new _display_names helper; CSP tightened to
default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and
2.12 corrected -- the Node page uses the loopback API, not MNP.

One capability is intentionally dropped: browser-based admin on a headless
server. The CLI covers every operation there.

See docs/refactor-node-ui.md.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
</content>
</entry>
<entry>
<title>fix(node): make WebRTC STUN fallback actually use every configured server</title>
<updated>2026-08-31T23:13:43Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-31T23:13:43Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b3b48a43a33f936be18a3daa22d307c144b60e22'/>
<id>urn:sha1:b3b48a43a33f936be18a3daa22d307c144b60e22</id>
<content type='text'>
aiortc's connection_kwargs() keeps only the first STUN URI from
RTCConfiguration.iceServers ("only a single STUN server is supported"),
and aioice.ice.Connection has a single stun_server field. So the node's
four default STUN servers -- and anything added on the Node page or with
`meshbay-node stun add` -- collapsed to stun:stun.l.google.com:19302. When
that one server was slow or unreachable from the node, ICE gathering
(get_component_candidates, timeout=5) burned its full 5 s with no
server-reflexive candidate, adding seconds to every browser connection.
The multi-server fallback of draft-v6 s2.12 was configuration only.

transport/stun_multi patches aioice.ice.server_reflexive_candidate (same
monkey-patch technique ice_filter.py uses on get_host_addresses) so a
single ICE gather races the STUN binding request against every configured
server on the one bound socket and takes the first answer. One reachable
server anywhere in the list now yields a reflexive candidate in one RTT.

- daemon: install_stun_multi() alongside install_ice_filter()
- ops.set_node_settings: push the list to stun_multi.set_servers() so the
  CLI / Node-page hot-swap takes effect without a restart
- webrtc_server.handle_offer: log ICE gather time and srflx count
- test_stun_multi.py: fan-out, first-answer-wins, all-fail, empty-list
  fallback, DNS failure

Verified end to end with a real RTCPeerConnection: with a black-hole STUN
server first in the list, gathering still completes in ~0.07 s with full
srflx candidates (previously a 5 s stall).

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01BSsQhfxEAhwi4nqc4hASmq
</content>
</entry>
<entry>
<title>feat: passphrase change and account recovery (auth-confirm)</title>
<updated>2026-08-31T23:03:43Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-31T23:03:43Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=fe30860c58e0f1b1efd457ff5eb5146d1e592da0'/>
<id>urn:sha1:fe30860c58e0f1b1efd457ff5eb5146d1e592da0</id>
<content type='text'>
The passphrase derives two independent client-side values: auth_key (the
hub verifier) and bundle_key (AES-GCM key for the per-node identity
bundles, which live on nodes and never on the hub). Changing or
recovering a passphrase is therefore two operations — swap the hub
verifier, and re-wrap every reachable node's identity bundle.

Flow A — change a known passphrase (Profile page)
- POST /v1/users/password re-proves the current passphrase, swaps
  pw_hash/salt/version, revokes every refresh token and returns a fresh
  pair so the tab that made the change stays signed in.
- MeshBayTransport.rewrapAllNodes: for every group's online node, connect
  with the old key, read the identity off the handshake, store it back
  under the new key. Returns updated / unreachable / failed so the UI can
  point at the operator-unpin fallback for the gaps. Always-shown
  confirmation dialog listing reachable and unreachable groups.

Recovery key
- keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and
  deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:&lt;username&gt;).
- Every per-node identity gets a second copy wrapped under the recovery
  key: keypair_bundles.bundle_enc_recovery (node-only column, added in
  _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs),
  carried on keypair_bundle_store / _resp. MNP 0.13 -&gt; 0.14, additive.
- session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded
  on connect, so a group joined in any later session still leaves a
  recovery copy.
- Shown once at registration; optionally folded into the verification
  e-mail as a pass-through the hub never stores or logs, with an opt-out.
- Profile -&gt; Recovery key re-loads R and backfills every reachable node
  via rewrapAllNodes in bundleKey mode (no passphrase re-entry).

Flow B — recover a lost passphrase (#/reset, linked from sign-in)
- POST /v1/users/password/reset-request {username, email}: both must be
  the pair on file, checked against the blind email_hash (never
  decrypted). A mismatch — wrong e-mail, unknown username, non-active
  account — takes the identical no-op path (no code, no mail, same 200),
  so it reveals nothing and cannot be used to spray reset mail from a
  username alone. 5/min, 1-hour single-use code.
- POST /v1/users/password/reset {username, code, new_auth_key}: same
  expiry / attempts / single-use checks as e-mail verification; revokes
  every session and deletes every registered device key so a stored one
  cannot sign back in past the reset.
- ResetPasswordPage: request code -&gt; code + optional recovery key + new
  passphrase -&gt; reset + sign-in -&gt; fan-out. connect() falls back to the
  recovery-wrapped copy when the passphrase key cannot open bundle_enc.
  Without a recovery key: sign-in is restored and each group needs the
  operator-unpin fallback.

Supporting fixes (found in live testing)
- member unpin now also deletes the keypair bundle; connect() mints a
  fresh identity when handed a bundle it cannot open (unless _rewrapOnly,
  set by rewrapAllNodes), so a rejoin completes instead of dead-ending
  before the invite-code prompt.
- A browser with no bundle key gets a passphrase prompt on the group page
  instead of a "go back to the browser you registered on" message.
- RegisterPage / LoginPage / ResetPasswordPage trim the username so every
  key derivation matches the hub's stored form.

Docs: docs/auth-confirm.md. Locale keys across all ten catalogues.
Tests: test_password_change, test_password_reset, test_recovery_email,
test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus
additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492
passed; node suite 741 passed (the lone test_packaging_units failure is a
pre-existing RPM-spec flake, reproducible on main).

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
</content>
</entry>
<entry>
<title>feat: configurable STUN server fallbacks for WebRTC ICE</title>
<updated>2026-08-30T20:17:30Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T20:17:30Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=07e6e4271ea3a45e9cd364eb6ec05801a653d9e8'/>
<id>urn:sha1:07e6e4271ea3a45e9cd364eb6ec05801a653d9e8</id>
<content type='text'>
The WebRTC transport relied on a single Google STUN server — if it was
unreachable, ICE gathering waited the full 4s timeout. Now four public
servers are used by default (Google ×2, Cloudflare, Mozilla), configurable
via node.toml, the Node page UI, and the CLI (meshbay-node stun list|add|
remove|reset). Changes are hot-swapped on the live transport.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>chore: scrub copyrighted names from tests, comments and docs</title>
<updated>2026-08-30T15:05:15Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T15:05:15Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d5fc45ff907e618d884a8d2d91c1f2b45e6898d1'/>
<id>urn:sha1:d5fc45ff907e618d884a8d2d91c1f2b45e6898d1</id>
<content type='text'>
Real franchise / show / release-group names had crept back into test
fixtures, code comments, a docstring and docs/mediacenter.md while fixing
the saga-match and misclassification bugs. Replace them all with invented
placeholders ("Some Saga", "A Different Show") and shape descriptions
("a franchise-origin film", "a 3-season show"). Behaviour and assertions
unchanged; 738 node tests still pass.

Record the rule in CLAUDE.md so it stops recurring.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
<entry>
<title>fix(node): stop a numbered saga all matching its first film</title>
<updated>2026-08-30T14:31:34Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T14:31:34Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=854b76338072979526ce36fbc176c82f6fc5d0bd'/>
<id>urn:sha1:854b76338072979526ce36fbc176c82f6fc5d0bd</id>
<content type='text'>
Every "&lt;Saga&gt; Episode &lt;N&gt; - &lt;subtitle&gt;" file in a numbered franchise
resolved to the series' first entry (a real, older film). `sequel_variants`
stripped "Episode &lt;N&gt;" and offered the bare "&lt;Saga&gt;" as a candidate query;
that matches the first film's original_title at ratio 1.0 and beat PASS 1's
correct-but-lower hit. A franchise's bare name is very often a real,
different film.

When a Part/Episode/Chapitre/… keyword carried the index, sequel_variants
no longer emits the bare base — only "&lt;base&gt; &lt;digit&gt;" and "&lt;base&gt; &lt;roman&gt;".
Without a keyword ("&lt;Franchise&gt; 3") the bare base is still offered, so that
fix is untouched. Verified live against TMDB: the franchise's episodes each
resolve to their own entry; the earlier numbered-sequel, two-part-film and
franchise-subtitle regressions all hold.

docs/mediacenter.md §10.3.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
<entry>
<title>fix(node): stop a movie with a mangled quality tag being shelved as a series</title>
<updated>2026-08-29T16:45:37Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-29T16:45:37Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=00880fd8b9d362ad391c784f1ee31bd62aa18d62'/>
<id>urn:sha1:00880fd8b9d362ad391c784f1ee31bd62aa18d62</id>
<content type='text'>
Found on demo35: "Some.Film.2017.MULTI.108.grp.mkv" — the release name's
"1080p" truncated to "108" — makes guessit read S01E08, so enrich.py's
flat-library branch (elif ep.episode is not None) filed a standalone film
as a series. "Fix match" then only offered TV results for the phantom
show, so there was no way out from the UI.

- title_parse.has_episode_marker(): true only for an explicit SxxExx /
  1x08 / "Episode N" / "Season N" token, not a bare 3-4 digit run.
- enrich.py: the flat-library branch now needs ep.season AND ep.episode,
  plus either a real marker or the absence of a "(2019)"-style year.
  Every genuine flat-dumped episode in the corpus carries a marker, so
  real shows are untouched; the same misparse on "1280" ("...2013.1280...")
  is covered too.

docs/mediacenter.md §10.2. Known gap left open: no operator control over
the movie/show kind itself — a "this is a movie / a show" toggle would.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
<entry>
<title>fix: on restart, serve a video's cached TMDB match instead of re-searching</title>
<updated>2026-08-29T15:57:01Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-29T14:42:18Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=32318a64b12c83429c156a36c228769492e41d4e'/>
<id>urn:sha1:32318a64b12c83429c156a36c228769492e41d4e</id>
<content type='text'>
Root of the 2026-08-29 demo35 storms. `_do_media_meta_request` could not
tell "this is a movie" from "this video isn't enriched yet" — both have
season/episode None — so during a slow initial scan with a browser on the
Videos tab, every show episode requested was run through the *movie*
search path with its raw filename as the query
(`search/movie?query=Show S01E01 1080p WEB DL ...`), hundreds per second,
until TMDB rate-limited and posters stopped loading. Worse, an
un-enriched show episode's own valid cached "tv" match was treated as
stale (its provisional kind was "movie"), so a file already resolved got
re-queried anyway.

- While a video is un-enriched (no display_title — enrich.py always sets
  one), never run a TMDB *search*. Serve the cached match if the content
  hash has one, honouring the cached kind ("tv"/"movie") rather than the
  provisional split; otherwise answer confidence 0.
- Once enriched, the strict `cached_media_type == media_type` check is
  unchanged: an enrichment fix that reclassifies a folder movie-&gt;tv still
  drops the stale match and re-resolves.
- video-app.js: `useMediaMeta` gains an `enrichSig` dependency
  (`entry.display_title`) so the client refetches once the enriched fields
  arrive on an index delta — the fileId is a content hash and never
  changes, so nothing else would retrigger it.

Not caused by the V8-V13 work; it raised the per-file call count so the
pre-existing race became a visible storm.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
<entry>
<title>feat: V13 — per-card "re-match this one file" (MNP 0.13)</title>
<updated>2026-08-29T13:57:06Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-29T13:57:06Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4d167e9f958d26c1db920274974ae446426928e3'/>
<id>urn:sha1:4d167e9f958d26c1db920274974ae446426928e3</id>
<content type='text'>
A one-click alternative to the full "Fix match" search-and-pick flow, and
reachable without SSH (`meshbay-node video rematch` clears a whole group).

- MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node
  logs "unknown type", the button just does nothing). OP_TMDB_REMATCH,
  signed like tmdb_override (media_cache is shared node-wide).
- media_cache.drop_tmdb_match(file_id): forgets the match AND the
  override marker — deliberately stronger than clear_file_tmdb, since the
  operator is explicitly asking for a fresh resolution.
- webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch +
  admin-response routing, broadcasts tmdb_rematch_ack.
- transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the
  admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack.
- video-app.js: a "Re-match" button beside "Fix match" in the detail
  modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one
  key in all ten locales.

docs/mediacenter.md §10.1: V8–V13 marked done.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
<entry>
<title>feat(node): V8–V11 — show-branch ladder, year-aware _best_match, wider sequel_variants</title>
<updated>2026-08-29T13:43:38Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-29T13:43:38Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=5d28d0c96cc8489645178b483835489779cb3887'/>
<id>urn:sha1:5d28d0c96cc8489645178b483835489779cb3887</id>
<content type='text'>
V8: the TV/show branch of _tmdb_search used the old "first candidate over
0.6 wins" shape. It now shares one _tmdb_ladder helper with the movie
branch — score every candidate query, keep the best, fast-path a
confident primary hit. A year lifted off the show's folder name
(title_parse.year_in, e.g. "Some.Show.2022.S01") rescues a sub-0.6 hit
that lands on the exact year. title_parse.clean_query de-dots a
folder-derived title without naive_title's extension-stripping trap.

V9: _best_match gains an optional `year`. When the top result is not a
confident textual hit (ratio &lt; 0.6) and a year was requested, a
different result of that exact release year is preferred — TMDB already
year-filtered the search, so this is a hard corroboration, not the fuzzy
re-rank §3.3 warns against. A confident top hit is never overridden.
search_movie/search_tv forward the year.

V10: sequel_variants widened — trailing Roman→digit as well as
digit→Roman, spelled-out indices (one..twelve / un..douze / ordinals),
and a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word
that is not an index or a 4-digit year.

V11: when the primary hit is already decent (&gt;= 0.6) and there is nothing
more specific to try (no alternative_title, no sequel variant — only a
punctuation restatement left), the ladder returns without the extra
requests. The clean-title common case is back to one call.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
</feed>
