1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
|
"""MeshBay common — shared crypto primitives and protocol types."""
__version__ = "0.11.0"
# 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are
# additive — an 0.1 peer sends no `before` and gets the newest page, which is
# what it wanted — so this is a MINOR bump, not a MAJOR one.
# 0.4: added `apps_enabled`/`apps_enabled_ack`, and `enabled_apps` on the
# handshake ack, for the group-applications registry. Additive — a node that
# predates it is never sent the op, and a client that predates it never looks
# for the field — so this is a MINOR bump too.
# 0.5: added `width`/`height`/`display_title`/`season`/`episode` to
# `IndexEntry`, started populating the already-declared `duration`/
# `thumb_hash`, and added `media_meta_req`/`media_meta_resp`, for the Videos
# group app. Additive — an older client simply doesn't render the new
# fields — so this is a MINOR bump too.
# 0.6: added `season_meta_req`/`season_meta_resp` (per-season TMDB overview,
# rather than one static show-level summary applied to every season alike)
# and `tmdb_search_req`/`tmdb_search_resp` + `tmdb_override`/`tmdb_override_ack`
# (an operator correcting a wrong automatic TMDB match, found live against a
# real show fragmented across TMDB entries per season). All additive.
# 0.7: added `tmdb_enabled`/`tmdb_enabled_ack` — whether TMDB is used moved
# from a node-wide setting to per-group (an operator running a real media
# library alongside test/demo groups on one node wants outbound TMDB traffic
# for one, not all of them). `tmdb_config`/`tmdb_config_ack` keep their name
# but now only cover the token/language, which stay node-wide. Additive: an
# older client simply never sends/handles the new pair.
# 0.8: added `artist`/`album`/`track_no` to `IndexEntry`, and
# `musicbrainz_config`/`_ack`, `musicbrainz_enabled`/`_ack`,
# `music_meta_req`/`_resp`, for the Music group app (docs/musicbay.md).
# Same shape as 0.5-0.7's Videos additions, and reuses `duration`/
# `thumb_hash`/`display_title` rather than declaring new ones. Additive:
# an older client simply doesn't render the new fields or send the new
# messages.
# 0.9: added `audio_transcode_req`/`_resp` — WMA and Musepack tag/cover
# fine but decode in no mainstream browser's <audio> element at all, so
# the node transcodes to AAC/M4A on request and caches the result the same
# way a TMDB poster or MusicBrainz cover is cached. Additive: an older
# client never sends the request, and simply can't play those two formats,
# same as before this existed.
# 0.10: added `audio_root`/`_ack` — the Music app's own entry point, same
# shape as `video_root`/`_ack`. Reverses musicbay.md's original "no root,
# whole shared tree" call: a real messy library showed that call was
# wrong, and Music now requires one before showing/enriching anything,
# exactly like Videos. Additive at the protocol level: an older client
# never sends the op and never expects the field.
# 0.11: added `taken_at`/`camera` to `IndexEntry` (best-effort, from a photo's
# own EXIF block) and `photo_roots`/`photo_roots_ack`, for the Photos group
# app (docs/photos.md). Unlike `video_root`/`audio_root`, `photo_roots` is a
# *set*, replaced whole in one signed op — a photo library is routinely
# scattered across several folders, not one. Additive: an older client
# never sends the op and never expects either field.
# 0.12: added `link_preview_req`/`link_preview_resp` — the node unfurls a URL
# pasted in chat into an OpenGraph card. Additive: an older node logs "unknown
# type" and the client just shows the bare link, as it always did.
# 0.13: added `tmdb_rematch`/`tmdb_rematch_ack` — an operator dropping one
# file's cached TMDB match so it re-resolves with the current matcher
# (§10.1/V13). Additive: an older node logs "unknown type", the client's
# button just does nothing.
# 0.14: added an optional `bundle_enc_recovery` field on `keypair_bundle_store`
# and `keypair_bundle_resp` — a second copy of the identity bundle wrapped
# under the account's recovery key, so a forgotten passphrase does not strand
# the identity (docs/auth-confirm.md §4.3). Additive: an older node ignores the
# field on store and never returns one; an older client never sends it.
# 0.15: `file_chunk` and `index_sync` had forked between the transports — WebRTC
# sent binary, unsigned chunks and plain index entries, QUIC sent base64 chunks
# with a per-chunk Ed25519 signature and a `GroupIndex.serialize()` envelope. One
# type, two shapes, a single consumer each and no test that they agreed. Both now
# come from one encoder (`protocol.file_chunk_wire`, `transport/wire.py`), which is
# QUIC adopting what WebRTC already sent. **Breaking on the QUIC wire**, and only
# there: the WebRTC shape — the one every deployed client speaks — is byte for byte
# what it was, and no QUIC client ships. Recorded as a MINOR bump for that reason;
# a deployed QUIC peer would have made it a MAJOR one.
# 1.0: `index_sync`, `index_delta` and the `handshake_ack` configuration
# fields now travel **sealed under a GEK-derived subkey**
# (`meshbay_common.groupbox`), and the handshake negotiates a supported
# version range instead of writing a `v` nobody reads.
#
# **Breaking, on the wire every deployed client speaks**, and there is no way
# to describe it as additive: an old client sends `index_sync` and gets a
# message with no `entries`; it reads `ack.enabled_apps`, finds nothing, and
# applies its documented fallback — "show every app" — rather than reporting
# an error; a new client against an old node finds `entries` it does not
# expect and no `ct`. 0.15 stayed MINOR because only the QUIC wire changed and
# no QUIC client ships; that argument is not available here, and MAJOR is what
# the project's own rule says. Hub and every node deploy together; the SPA is
# served by the hub, so a browser picks up the new client on reload.
#
# Version negotiation ships in the same flag day rather than after it (phase
# 15.6): the coordinated deployment is already being paid for, and it is what
# makes the *next* breaking change cost a refusal message instead of a second
# flag day. `MNP_MIN_SUPPORTED` in `handshake.py` is the other half.
#
# **2.0 (2026-09-07): the write path is sealed, chat is encrypted, and the last
# unencrypted content message is gone.** Three changes that landed together and
# share one version, because they share one flag day.
#
# - `file_upload` and `file_upload_ack` travel sealed under a GEK-derived
# subkey (`groupbox.PURPOSE_UPLOAD`). The filename, the destination folder
# and the bytes all ride inside the seal; `upload_id` — a fresh
# client-chosen correlation id — and `chunk_index` stay in clear because
# the node routes and orders on them. `filename` used to be the
# correlation key and cannot be any more, which is what forced `upload_id`.
# - `chat_msg` is sealed under a per-device subkey of the group's chat epoch
# key (`meshbay_common.chatbox`) and signed over the ciphertext with the
# device key the node pinned. There is deliberately **no per-group switch**:
# every node in existence is a test node, so an opt-in flag would buy
# nothing and leave a plaintext branch reachable. Existing node data is
# migrated by `QE/migration/migrate_chat_encryption.py`.
# - `stream_seg` is **removed**. It answered with an MPEG-TS segment as
# base64 with no encryption at all, on both transports, to any
# authenticated member — the one content-plane message that never went
# through a GEK-derived key. `stream_data` has done the job properly since
# Phase 12, and `fetchStreamSegment`, its only browser caller, was defined
# and never once invoked.
#
# **Breaking, on the wire every deployed client speaks**, and MAJOR by the same
# rule 1.0 was.
#
# **`MNP_MIN_SUPPORTED` moves to "2.0" with it, and that is a change of plan
# worth reading.** The sealed upload alone was a *confined* break: a 1.x peer
# could still connect, browse, download, stream and chat, and only its uploads
# were refused — so the floor stayed at "1.0" and the refusal was per message
# (`upload_not_sealed`). Mandatory chat encryption ends that confinement. A 1.x
# peer can neither produce a sealed chat message nor read one, so it would
# connect, appear to work, and then be unable to say anything or read anything
# anyone else said. Refusing it at the handshake with `version_too_old` and a
# sentence saying so is the honest form: a stated refusal is a bug report, a
# chat that quietly does not work is a support case. The per-message
# `upload_not_sealed` path stays, unchanged — it is still the right answer if
# the floor is ever lowered again.
#
# Still deliberately in clear, and none of it is content: the handshake itself,
# `index_progress` (counters only — see daemon.py `_push_index_progress`), the
# admin and configuration acks, and the media-metadata replies. The index at
# rest and file content on the operator's disk are unchanged.
MNP_VERSION = "2.0"
MHP_VERSION = "0.1"
|