<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/docs/meshbay-draft-v6.md, branch 0.16</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.16</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.16'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-10T22:19:06Z</updated>
<entry>
<title>docs: remove the documents MESHBAY_DESIGN.md replaces</title>
<updated>2026-09-10T22:19:06Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T22:19:06Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f059cb118c556d1f0279350507f74b8a47d5a98a'/>
<id>urn:sha1:f059cb118c556d1f0279350507f74b8a47d5a98a</id>
<content type='text'>
Twenty-four files, about 17 000 lines: the two architecture drafts, the three
security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4
archive, the deprecated user guide and the stale quickstart. Their content is in
MESHBAY_DESIGN.md, and git history holds the originals.

The reason to delete rather than keep bannered: a document that is superseded but
present still gets read, and a reader cannot always tell which of two accounts of
one mechanism is the live one. That was the argument for retiring the user guide
rather than repairing it, and it applies to the whole set.

What made this safe is the concordance. Roughly 290 comments and docstrings cite
these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11`
— and section 16 maps every one onto its replacement, so not a single comment
needs editing to stay followable. It now says plainly that the files are gone and
where to recover them, and it gained rows for the three reviews (their findings
are section 13), and for the two guides.

Four kept documents pointed into the set and were repointed first: `playlists.md`
(nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`,
and CLAUDE.md's example. No dangling reference remains outside section 16.

Two files were dropped from the list after checking what they hold. `HTTPS.md` is
an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and
MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it;
the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is
the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and
section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work.

There is no user guide now, and section 0.1 says so rather than leaving a reader
to discover it.

Suites green: 2258 passed, 4 skipped.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
</content>
</entry>
<entry>
<title>docs: point the superseded drafts at the design document</title>
<updated>2026-09-10T16:45:03Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T16:45:03Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d82a0e7eec97844cd5eaf8aa49315146d5ed3d33'/>
<id>urn:sha1:d82a0e7eec97844cd5eaf8aa49315146d5ed3d33</id>
<content type='text'>
Twenty-three documents that MESHBAY_DESIGN.md absorbs gain a header saying so
and naming the sections their content went to. None is deleted: code comments,
tests and the documents themselves cite their sections and their labels, and
each records reasoning a synthesis compresses.

The header states the precedence, because two documents describing one system
will disagree eventually: where a draft disagrees with MESHBAY_DESIGN.md the
design document is right, and where either disagrees with the code the code is.

Seven status lines were corrected on the way through, all of them claiming less
than the truth. Videos, Music, Photos, partial-read hashing and account recovery
were headed "proposal, not implemented" months after they shipped; the desktop
client said "nothing here is implemented" with stages A through D running; draft
v6 still called itself the current specification.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
</content>
</entry>
<entry>
<title>fix(node): a video the browser cannot decode is re-encoded, not refused</title>
<updated>2026-09-09T15:16:29Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T15:16:29Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0b9b85bf2d434177968dfc6e27555d54d703ff7e'/>
<id>urn:sha1:0b9b85bf2d434177968dfc6e27555d54d703ff7e</id>
<content type='text'>
Streaming an Xvid/MP3 .avi answered "Unsupported video codec" — a refusal, on
a file ffmpeg re-encodes at about six times playback speed on the machine that
reported it. Nothing about the source was wrong. The node simply never reached
its own re-encode path.

`probe_video` maps a source codec to an MSE codec string and knows four:
h264, hevc, vp9, av1. Everything else returns None, because there is no
MediaSource decoder in any mainstream browser to give a string to — MPEG-4
Part 2 (Xvid, DivX), MPEG-2, VC-1, WMV, Theora. `_stream_video_inner` read
that None as a verdict on the file and refused, while the re-encode sitting
twenty lines below it was gated on `raw_video_codec in
BROWSER_INCOMPATIBLE_VIDEO_CODECS` — a set containing "hevc" and nothing else.
So the whole ffmpeg fallback existed, worked, and was unreachable for every
codec that most needed it.

The setting that governs the fallback has documented the intended behaviour
since it was introduced: draft-v6 §2.11 says `transcode_incompatible_video`
covers "HEVC *and other browser-incompatible video codecs*". Only HEVC was
ever wired up.

Two questions were being answered by one value, and they are separated now.
"Is there a video stream at all" is the only thing this path genuinely cannot
serve, and the only refusal left. "Can it be copied" needs both an MSE string
to put in `stream_init` and a codec browsers decode; a source failing either
is re-encoded.

The operator's opt-out keeps meaning what it says, and it no longer means the
same thing for every source, because it cannot: HEVC has a codec string, so
`transcode_incompatible_video = false` falls back to a copy and the viewer's
own decoder decides (unchanged). MPEG-4 Part 2 has none, so there is nothing
to fall back to — a `stream_init` with no codec string is one the client
refuses before the first byte — and the stream is refused naming the setting.
"Unsupported video codec" is what sent this report to the file, and the file
was fine.

Verified against the reported file end to end: ffprobe reports mpeg4/mp3
720x404, the decision comes out `can_copy=False`, and the pipeline's exact
argv produces H264 High level 4.1 plus stereo AAC-LC — matching the
`avc1.640029,mp4a.40.2` that `stream_init` advertises and that the client puts
through MediaSource.isTypeSupported byte for byte.

test_stream_hevc_transcode.py becomes test_stream_video_transcode.py: it was
always about the policy rather than about one codec, and it now carries both
halves of it, with a synthetic Xvid/MP3 .avi built the same way as the HEVC
clip. Its module-level skip on libx265 went with it — an ffmpeg without x265
still encodes MPEG-4 Part 2, so that marker was skipping the reported defect
entirely on any box without it; it now gates the HEVC cases alone. Three
cases added, checked against the unfixed source. Hub and node suites 2269
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>feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)</title>
<updated>2026-09-07T15:50:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-07T15:50:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=36cebf25d0e0f24cf63be4380ccb5d03da726a74'/>
<id>urn:sha1:36cebf25d0e0f24cf63be4380ccb5d03da726a74</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
</content>
</entry>
<entry>
<title>feat(node): an upload lands in the folder it was sent to</title>
<updated>2026-09-06T20:06:45Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-06T20:06:45Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f3fb449f3a943096a2569dc383f2819a612bccd5'/>
<id>urn:sha1:f3fb449f3a943096a2569dc383f2819a612bccd5</id>
<content type='text'>
There is no `uploads/` subdirectory any more, and the client names the folder
rather than the root.

It was the last of v5's quarantine — the per-user layer went on 2026-08-14 for
the same reason — and it goes on the same grounds: a folder appearing beside
the operator's library because somebody sent a file is the node deciding how
their disk is arranged. Somebody dropping a file into the folder they are
looking at expects it to be in that folder.

**What made the quarantine worth having was never the subdirectory.** It is the
filename allowlist, the size cap, the chunk ordering and the no-overwrite rule,
and all four are untouched: an existing file is never replaced, the second
sender of IMG_1234.jpg gets a free name, and the check still sits at the write.

Letting the client choose the destination is safe for one reason and only one:
it is resolved through `RootSet.resolve()`, which refuses `..`, absolute
segments and anything whose resolved form escapes its root, symlinks included.
A member answers "which of this group's folders", never "which path on the
operator's disk" — and the test that used to assert the node chose now asserts
that, with six shapes of escape.

`direct` goes with it. Its only job was to say "no subdirectory for this root",
which is now every root, and a config flag that does nothing is worse than none.

Chat's attachment folder finally does something: the directory the operator
picks in the Chat settings pane is where attachments are written, falling back
to the first writable root while they have not chosen one, or if the one they
chose has since been made read-only or ejected — a stale choice should not
become a refusal at send time.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
</content>
</entry>
<entry>
<title>fix(groups): finish Phase 1 — MNP root management, upload targets, eject state</title>
<updated>2026-09-06T15:48:36Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-06T15:48:36Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=ea56b8c79538323875c00db2e7006b255f7cd494'/>
<id>urn:sha1:ea56b8c79538323875c00db2e7006b255f7cd494</id>
<content type='text'>
Review of the Phase 1 commit found the RO/RW model sound but three paths
unfinished, each of which broke the flow the phase exists to deliver. Plus
29 test failures it introduced and no coverage for anything it added.

Uploads went to the wrong directory. The node read a `root` field on
file_upload that no client ever sent, so every upload landed in the first
writable root while the Files toolbar offered its button based on the root
being browsed — with two writable roots, uploading from one wrote into the
other. Files now names the root it is showing; Chat names one chosen in the
shell (an operator-configured directory arrives in Phase 2); the node refuses
an unknown name rather than falling back, and refuses read-only and ejected
roots by code.

Shared directories were unreachable on the web. The table read its roots
only from the loopback API, which resolves to "not available" in a browser,
so the section rendered for nobody there — while the Uploads controls it
replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods
beside it were dead. MNP is now the path, loopback the fallback for a local
node with no live connection, and adding a root over MNP takes a typed path
since no web page can browse a remote disk.

Ejecting updated nobody's screen. transport.js resolves an admin ack against
the pending request and returns, which is right for every op whose caller
knows the value it chose; the root acks carry state only the node can compute,
so the operator who clicked Eject was the one client that never saw it happen.
And the ejected flag reached roster.db but was never read back, so a restart
undid it and the next scan read an empty mount point as an erased library.

Also: the member-upload endpoint answered 200 and did nothing (removed); the
wizard ignored the first root's RW switch; reload compared roots on name and
path, so editing writable in node.toml did nothing; the table had no path
column, which is the only thing separating two libraries sharing a basename;
apps_enabled normalisation differed between the two sides of a signed subject.

Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no
coverage at all. test_member_upload_policy.py is replaced by
test_root_writable_policy.py — it tested a removed feature — and every
property worth keeping from it moved rather than being dropped.

Docs: draft-v6 structural decision 9 is annotated as superseded (the operator
can no longer have a directory only they may write to — a real capability
removed, flagged rather than hidden), the man page documents the root verb and
the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong.

Suite: 41 failures before, 13 after — all 13 pre-existing on main.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
</content>
</entry>
<entry>
<title>fix: drop the retired Mozilla STUN server from the defaults</title>
<updated>2026-09-04T12:51:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-04T12:51:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=08b0f8d7c457e5e58a31bea2b2e43c9f398a6923'/>
<id>urn:sha1:08b0f8d7c457e5e58a31bea2b2e43c9f398a6923</id>
<content type='text'>
stun.services.mozilla.com no longer resolves — Mozilla shut the service
down — so every ICE gather waited out a DNS timeout on it. Removed from the
node defaults (config.py), the browser defaults (transport.js) and the Node
page's "reset to defaults" (node-page.js). Google (two endpoints) plus
Cloudflare still give two-provider coverage against a single outage, which
is the §2.12 resilience claim. draft-v6 §2.12 updated.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<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>chore: bump version to 0.9.0</title>
<updated>2026-08-31T10:05:24Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-31T10:05:24Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f4c6628c8e85513d9fd110ead95682368a15a0fd'/>
<id>urn:sha1:f4c6628c8e85513d9fd110ead95682368a15a0fd</id>
<content type='text'>
Packaging system complete and verified on Ubuntu 26.04 and Fedora 44.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>docs: add §2.12 STUN fallbacks and ICE filtering to draft v6</title>
<updated>2026-08-30T21:22:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T21:22:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3706dc15ff3be7acf2ade77977a9fca7a8b4efc5'/>
<id>urn:sha1:3706dc15ff3be7acf2ade77977a9fca7a8b4efc5</id>
<content type='text'>
Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
