<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/src/meshbay_node/ops.py, branch 0.11</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.11</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.11'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-04T00:35:41Z</updated>
<entry>
<title>fix(node): make init, node.toml editing and CLI output work on Windows</title>
<updated>2026-09-04T00:35:41Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-04T00:35:41Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=c2620a5b269db75fcadb772e3ae5250886e8814c'/>
<id>urn:sha1:c2620a5b269db75fcadb772e3ae5250886e8814c</id>
<content type='text'>
Found by running the daemon on Windows for the first time:

- `meshbay-node init` wrote `unlock_file = "C:\Users\..."`, and
  attach_group / add_root write `path = "C:\..."` — a raw Windows path in a
  TOML basic string is a parse error (`\U`, `\a`, ... are escape sequences),
  so the config would not load. All now write `Path(...).as_posix()`;
  pathlib reads the forward-slash form fine on Windows.
- any `print()` carrying a `-&gt;` arrow or em dash (the CLI help and messages
  are full of them) raised UnicodeEncodeError on a cp1252 console and took
  the command down. New `platform.force_utf8_stdio()` reconfigures
  stdout/stderr to UTF-8, called at the top of `main()`.

Verified on Windows: init writes parseable LF node.toml, the keystore
Argon2-decrypts, the loopback control API binds 127.0.0.1, and
`_update_node_toml` reads a CRLF file and rewrites it LF-only with its
standalone comments intact. Two regression tests added in test_ops.py.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): pin utf-8 (and LF) on every text file the node reads or writes</title>
<updated>2026-09-04T00:20:48Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-04T00:20:48Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=7a4b905ddb64bdc92b7f9acf2ccde9bd84d7a6f3'/>
<id>urn:sha1:7a4b905ddb64bdc92b7f9acf2ccde9bd84d7a6f3</id>
<content type='text'>
node.toml, the keystore envelope, the unlock key, the loopback UI token,
pairing/invite code files and the denylist were all read and written with
the platform default encoding and newline translation. On Windows that is
cp1252 + CRLF: a node.toml or keystore holding any non-ASCII byte failed to
load, and ops.py's line-based node.toml editor round-tripped CRLF in and
LF out.

Every read is now `encoding="utf-8"`; every write is `encoding="utf-8",
newline="\n"` so the files stay LF whatever the OS. No-op where the locale
was already UTF-8.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): an invitation the hub never registered is a code nobody can use</title>
<updated>2026-09-03T09:26:00Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-03T09:26:00Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=ba523e6ba3cb02f9b9612b74596d09c45e22dbdb'/>
<id>urn:sha1:ba523e6ba3cb02f9b9612b74596d09c45e22dbdb</id>
<content type='text'>
`create_invite` wrote the invite to the roster and *then* asked for the
hub. An unreachable hub therefore raised "Hub not connected" after the
code was already stored: the operator saw an error and no code, and a
valid invitation sat in the roster that nobody had been given. Every
retry left another.

Registering first means a failure costs nothing — no code exists to be
orphaned. A membership row without an invite is harmless: without the
code there is still no group key. The endpoint is idempotent (`if not
mem: db.add(...)`, no 409), so the SPA registering the same membership
again right after createInvite costs nothing either.

The registration is now fatal rather than swallowed, which is the part
that matters. `/v1/groups/mine` joins GroupMember, so someone who was
never registered does not see the group at all and can never redeem the
code. Tolerating that failure handed the operator a code that cannot
work and said nothing — a worse outcome than the error, because it is
silent. Skipped only when there is no username to register with: the MNP
path allows an empty one and there the SPA is the one that registers.

Found by test_invite_then_join_delivers_the_gek, whose fixture had no
hub and which passed only because the failure was swallowed. It has one
now. And 0443cf8 added this registration to the CLI path without any
test asserting it happened, which is how it came to be skipped whenever
the hub was merely absent — test_cli_invite_asks_the_hub_for_an_account_
never_a_key checks it now, and
test_an_unreachable_hub_leaves_no_invite_behind covers the orphan
(verified failing against the previous ordering).

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
</content>
</entry>
<entry>
<title>fix(node): CLI member invite now registers hub membership and enforces code</title>
<updated>2026-09-01T09:07:05Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T09:07:05Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0443cf8aaab58a54c73d28cafca5d5d8a52605e6'/>
<id>urn:sha1:0443cf8aaab58a54c73d28cafca5d5d8a52605e6</id>
<content type='text'>
Two bugs fixed:

1. `meshbay-node member invite &lt;user&gt;` created a local roster invite
   but never told the hub to add the user to group_members, so the
   group was invisible in the SPA. The node now calls
   POST /v1/groups/{id}/members/{username} after creating the invite,
   and the hub endpoint accepts node-scoped tokens (the admin_id
   check is the real authorization guard).

2. The WebRTC handshake let a previously-pinned user reconnect without
   a code even when a new invite was pending (e.g. after leave + re-invite).
   Now any pending invite forces code entry, regardless of existing
   member/pin status.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</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(ui): configurable ICE interfaces in the Node page</title>
<updated>2026-08-30T20:36:56Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T20:36:56Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2d6c276734fd19f80edf84cdb95f91a929df6365'/>
<id>urn:sha1:2d6c276734fd19f80edf84cdb95f91a929df6365</id>
<content type='text'>
The ice_interfaces setting (auto-exclude vs explicit whitelist) is now
editable from the Node page, persisted via the settings API and roster,
and hot-swapped at runtime by re-installing the aioice filter.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</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>feat(node): editable node settings in the Node page (D5)</title>
<updated>2026-08-29T16:52:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-29T16:42:43Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1f8a52484412b48205e5ff6aac506428e2fb77ed'/>
<id>urn:sha1:1f8a52484412b48205e5ff6aac506428e2fb77ed</id>
<content type='text'>
Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes,
max_concurrent_streams and transcode_incompatible_video in the Node
management panel. Changes are applied immediately via roster.db and
written back to node.toml so they survive a DB wipe. On startup,
roster overrides take precedence over node.toml defaults.

Draft v6 §2.11 documents the design; MNP gains node_settings_set /
node_settings_set_ack for the browser path.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): correct TMDB movie matching, per-file overrides, rematch</title>
<updated>2026-08-29T12:58:58Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-29T12:58:30Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=71b7a310ce938f072fe20f27eeeadd40685f1ad1'/>
<id>urn:sha1:71b7a310ce938f072fe20f27eeeadd40685f1ad1</id>
<content type='text'>
A batch of wrong poster-grid matches found live on a real library
(2026-08-29): a two-volume film's second part matched the first; a
numbered sequel matched a same-year making-of documentary; several
entries of one franchise matched a single early entry whose localized
TMDB title is the franchise name; one matched nothing. One mechanism:
_tmdb_search returned the first candidate query whose title-similarity
ratio merely cleared 0.6, before alternative_title / the Roman-numeral
variant was ever tried.

Matching:
- title_parse: fold guessit's volume/part number back into display_title
  so the parts of a multi-part film stay distinct in the query, the card
  and the override.
- _tmdb_search: keep a strong PASS 1 fast path (ratio &gt;= 0.85, one
  request), otherwise score every candidate query and pick the best. A
  year-exact rescue lifts a sub-0.6 top hit to the confidence floor only
  when TMDB's own year-filtered result lands exactly on the filename's
  year. No local re-ranking of any single result list; no tmdb.py change.

Fix match / rematch:
- _admin_exec_tmdb_override: a movie override touches its own file only
  (guessit gives a whole franchise one display_title); a show override
  still fans out. Corrected files are marked in media_cache.tmdb_override.
- media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches
  drop auto-resolved matches while sparing manual corrections.
- ops.rematch_video + `meshbay-node video rematch` (loopback endpoint +
  CLI verb): re-resolve a group's video matches after a matcher fix.
  file_tmdb is keyed by content hash and otherwise only pruned on
  deletion, so nothing dislodged a cached match before.
- a rename now drops the stale auto match too (daemon
  _reenrich_renamed_video_entries).

UI:
- VideoDetailModal shows the source filename and resolved TMDB id; an
  unmatched poster gets a badge (3 new video.* i18n keys x 10 locales).
  So a wrong match can actually be identified before hitting Fix match.

docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog
(show-branch ladder, year-aware _best_match, wider sequel_variants, the
0.6-0.85 extra calls, movie grid merge, per-card rematch).

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