""" MeshBay protocol constants and message type definitions. MNP (Mesh Node Protocol) — v0.1 MHP (Mesh Bay Hub Protocol) — v0.1 All wire messages are length-prefixed msgpack (4-byte big-endian length header). Every message carries a "v" field for protocol version. **`req_id` — the correlation id (added 2026-09-07).** A request may carry one; the reply to it carries the same value back, and nothing else on the wire does. It is the caller's own key for its pending request, opaque to the node, and unique only within one connection. There was none for a long time, and its absence was not neutral. A reply named its own type and nothing else, so a caller with more than one request in flight had to work out which one a message answered from the message itself — and the replies that name nothing (a bare `ack`, and `{"type": "error"}`, which webrtc_server.py sends from 240 places while two of them say what they are about) could only be matched by arrival order. That is a guess, wrong whenever two replies reorder, and it does not fail quietly: one request is resolved with another's answer while the request that answer belonged to waits out its own timeout. Live symptom (2026-09-06): a chat send whose reply went astray left the composer disabled for thirty seconds, and the Chat tab read as frozen. Both halves are optional and degrade to what came before: a request without one is answered without one, and a client that gets no id back falls back to matching by type. Neither side may treat it as authentication or as a sequence number — it is a label chosen by the peer, and the only thing it decides is which local promise a reply belongs to. """ import os from dataclasses import dataclass, field # The wire versions live in meshbay_common/__init__.py — one source, because a # second copy here said "0.1" while every message on the wire carried "0.2". # Nothing imported it, which is the only reason it was harmless. from meshbay_common import MHP_VERSION, MNP_VERSION # noqa: F401 (re-export) from meshbay_common.groupbox import PURPOSE_UPLOAD, seal, unseal from meshbay_common.webcrypto import ( chunk_key_aes, decrypt_chunk_aes, encrypt_chunk_aes, ) # ── MNP message types ───────────────────────────────────────────────────────── class MNP: HANDSHAKE = "handshake" HANDSHAKE_ACK = "handshake_ack" INDEX_SYNC = "index_sync" # full Mesh Group Index INDEX_DELTA = "index_delta" # incremental update # Node -> already-connected members: "the operator's node is scanning # right now, N/M bytes done". Never the entries themselves (that is # INDEX_SYNC/INDEX_DELTA's job) — just enough for a presence dot to # animate. Pushed periodically while scanning, and once more on the # transition back to idle, so the indicator is guaranteed to turn off. INDEX_PROGRESS = "index_progress" FILE_REQUEST = "file_req" # request chunk(s) FILE_CHUNK = "file_chunk" # encrypted chunk response # STREAM_SEGMENT ("stream_seg") was removed in MNP 2.0. It served an # MPEG-TS segment as base64 **with no encryption at all** — the one message # on the content plane that never was under a GEK-derived key. It predates # STREAM_DATA, which does the same job properly (`chunk_ciphertext`, keyed # per segment), and its browser caller `fetchStreamSegment` was defined and # never once invoked. A live handler on both transports, plaintext media, # and no client: removed rather than repaired. # # Not a Double Ratchet message, and never was — finding C1 # (`docs/MESHBAY_DESIGN.md` §13.1) rejected exactly that for groups. Since # MNP 2.0 it is AES-256-GCM under a # per-device subkey of the group's chat epoch key, signed over the # ciphertext with the sending device's pinned Ed25519 key. There is no # plaintext form on the wire (`chatbox.py`, docs/MESHBAY_DESIGN.md §4.5); # `format` distinguishes a *stored* pre-2.0 row, which is still served. CHAT_MESSAGE = "chat_msg" # one chat message, sealed and signed CHAT_ATTACHMENT = "chat_attach" # attachment metadata CHAT_HISTORY = "chat_hist" # request message history (newest, or before a cursor) CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages # Link unfurl: the node fetches a URL a member pasted and returns an # OpenGraph card. Additive (0.12) — an older node just logs "unknown type" # and the client shows the bare link, exactly as before. LINK_PREVIEW_REQ = "link_preview_req" # client → node: unfurl this URL LINK_PREVIEW_RESP = "link_preview_resp" # node → client: card fields, or ok:false # Liveness on an *already open* channel. A peer that goes away without # closing leaves a DataChannel that still reads as connected until the next # real request hangs, and there was no way to ask. This is not a discovery # mechanism: opening a connection in order to ping costs a full ICE/DTLS # handshake (measured at 0.6-7 s across two ISPs), so presence in the group # list comes from the hub's socket registry instead. PING = "ping" PONG = "pong" # GEK_REQUEST / GEK_RESPONSE were removed (NS3, and finding L1): the node must # never serve the GEK in plaintext. Members obtain it by unwrapping their own # ECIES bundle. The constants lingered after the handlers were deleted, leaving # the wire contract looking as though the endpoint still existed. # Transfer slots. A download is otherwise invisible to the node -- a series # of independent file_req messages, with nothing saying one started or # ended -- so there is nothing to count and nothing to cap. The lease is # that missing object: `tr` is drawn by the client like `upload_id`, covers # a job rather than a file, and dies with the connection. # # One reply type with a state field, not four: a client that must switch on # the message type to discover it is still waiting is a client that will get # one branch wrong. Carries no filename and no path -- `tr` is opaque, # `bytes` and `chunks` are numbers -- so it stays in clear like # INDEX_PROGRESS, for the same stated reason. TRANSFER_OPEN = "transfer_open" # client -> node: I want a slot TRANSFER_CLOSE = "transfer_close" # client -> node: I am done with it TRANSFER_STATE = "transfer_state" # node -> client: granted/queued/closed FILE_UPLOAD = "file_upload" # client pushes file chunk to node FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt DIR_CREATE = "dir_create" # client → node: make a directory DIR_CREATE_ACK = "dir_create_ack" # node → client: created DIR_DELETE = "dir_delete" # client → node: remove an empty directory DIR_DELETE_ACK = "dir_delete_ack" # node → client: removed FILE_DELETE = "file_delete" # client requests file deletion FILE_DELETE_ACK = "file_delete_ack" # node confirms deletion STREAM_REQUEST = "stream_req" # client requests MSE video stream STREAM_INIT = "stream_init" # node sends codec info + signals stream start STREAM_DATA = "stream_data" # node sends encrypted fMP4 segment STREAM_END = "stream_end" # node signals end of stream STREAM_MORE = "stream_more" # client → node: room for N more segments STREAM_STOP = "stream_stop" # client → node: nobody is watching any more EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push HANDSHAKE_CHALLENGE = "handshake_challenge" # node → client: GEK proof nonce HANDSHAKE_RESPONSE = "handshake_response" # client → node: HMAC(GEK, nonce) ADMIN_CHALLENGE = "admin_challenge" # node → client: Ed25519 sign challenge ADMIN_RESPONSE = "admin_response" # client → node: Ed25519 signature # GEK_BUNDLE_STORE was removed with the invite redesign: the node wraps the GEK # itself, for a key the recipient proved possession of, so no member ever hands # the node key material (C5b, and the H3 substitution it enabled). GEK_BUNDLE_FETCH = "gek_bundle_fetch" # client → node: request own wrapped GEK GEK_BUNDLE_RESP = "gek_bundle_resp" # node → client: wrapped GEK bundle KEYPAIR_BUNDLE_STORE = "keypair_bundle_store" # client → node: store encrypted keypair bundle # optional `bundle_enc_recovery` (MNP 0.14): a second copy wrapped under the # account's recovery key (docs/MESHBAY_DESIGN.md §3.6) KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch" # client → node: request own keypair bundle KEYPAIR_BUNDLE_RESP = "keypair_bundle_resp" # node → client: encrypted keypair bundle # carries `bundle_enc_recovery` too when the node has one stored KEYPAIR_BUNDLE_DELETE = "keypair_bundle_delete" # client → node: withdraw own backup # Per-account blobs the node holds and cannot read — playlists (MNP 3.1, # docs/playlists.md §8.1). `kind` is "playlists" (the manifest) or # "playlist:" (one playlist's tracks); `blob_enc` is msgpack `bin`, # not base64, because these run to hundreds of kilobytes. USER_BLOB_STORE = "user_blob_store" # client → node: write one blob USER_BLOB_FETCH = "user_blob_fetch" # client → node: read one blob USER_BLOB_LIST = "user_blob_list" # client → node: which kinds, at what rev USER_BLOB_DELETE = "user_blob_delete" # client → node: drop one blob USER_BLOB_RESP = "user_blob_resp" # node → client: one blob, or null USER_BLOB_LIST_RESP = "user_blob_list_resp" # node → client: kinds and revs only JOIN_REQUEST = "join_request" # client → node: pair/recognise this identity JOIN_RESULT = "join_result" # node → client: outcome + wrapped GEK INVITE_CREATE = "invite_create" # operator → node: issue a pairing code INVITE_LINK_CREATE = "invite_link_create" # operator → node: a code bound to no account INVITE_CANCEL = "invite_cancel" # operator → node: take back an unredeemed link MEMBER_REVOKE = "member_revoke" # operator → node: stop serving the key MEMBER_REVOKE_ACK = "member_revoke_ack" MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity MEMBER_UNPIN_ACK = "member_unpin_ack" APPS_ENABLED = "apps_enabled" # operator → node: which group apps to show APPS_ENABLED_ACK = "apps_enabled_ack" TRANSFER_LIMITS = "transfer_limits" # operator → node: per-member caps here TRANSFER_LIMITS_ACK = "transfer_limits_ack" # node → this group: the new caps SET_SCAN_SETTINGS = "set_scan_settings" # operator → node: reconcile/debounce timing SET_SCAN_SETTINGS_ACK = "set_scan_settings_ack" MEDIA_META_REQ = "media_meta_req" # client → node: TMDB metadata for a path MEDIA_META_RESP = "media_meta_resp" # node → client: TMDB metadata (or none) TMDB_CONFIG = "tmdb_config" # operator → node: token/language, node-wide TMDB_CONFIG_ACK = "tmdb_config_ack" # node → everyone: config, never the token TMDB_ENABLED = "tmdb_enabled" # operator → node: TMDB on/off here TMDB_ENABLED_ACK = "tmdb_enabled_ack" # node → this group: TMDB on/off here SEASON_META_REQ = "season_meta_req" # client → node: one season's overview SEASON_META_RESP = "season_meta_resp" # node → client: season fields, or none TMDB_SEARCH_REQ = "tmdb_search_req" # client → node: candidates for a query TMDB_SEARCH_RESP = "tmdb_search_resp" # node → client: id, title, year, poster TMDB_OVERRIDE = "tmdb_override" # operator → node: replace a match TMDB_OVERRIDE_ACK = "tmdb_override_ack" TMDB_REMATCH = "tmdb_rematch" # operator → node: drop one file's match TMDB_REMATCH_ACK = "tmdb_rematch_ack" # Music app (docs/MESHBAY_DESIGN.md §9.8). Contact is derived from the # owner's hub email at login — no config/ack pair needed. Only the # per-group toggle remains. MUSICBRAINZ_ENABLED = "musicbrainz_enabled" # operator → node: enable/disable MUSICBRAINZ_ENABLED_ACK = "musicbrainz_enabled_ack" # node → this group: new enabled state MUSIC_META_REQ = "music_meta_req" # client → node: metadata for a path MUSIC_META_RESP = "music_meta_resp" # node → client: metadata (or none) # A file whose format the browser's own