diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
| commit | 2916aa376009283305a7acec4aafa3c96544499e (patch) | |
| tree | bc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-node | |
| parent | 25f62e169e049f0757ef611d5b409e7523958dee (diff) | |
| download | meshbay-2916aa376009283305a7acec4aafa3c96544499e.tar.gz | |
playlists: make the writes actually leave the browser
Reported from a phone: signing in with the same account showed no
playlists. syncWith was called from exactly one place in the interface,
so creating a playlist, deleting one, removing a track and saving the
queue all wrote to IndexedDB and stopped there. The store pushes itself
now, coalesced, so a new mutation cannot forget to.
Silence was the real defect. The node audited only successes, so a
refusal left no trace and user_blob_list none at all; the background
push swallowed its reason; the interface said nothing. All three report
now, and "Sync now" says what happened either way.
An unreadable blob on a node was treated as a fetch failure and returned
before the push — permanent, once the node held anything. It is an
absence: the client is the authority, and it gets overwritten.
A sign-in reconciles whatever this browser already holds, a pending push
is flushed when the page goes away, and a push that did not land is
retried once.
no_key is spelled out: a client that signs in with its remembered device
key only ever has a bundle key persisted before the playlist subkey
existed, and an AES handle is non-extractable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 28 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_user_blob_mnp.py | 31 |
2 files changed, 51 insertions, 8 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 298b3cd..99ba3c9 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -1178,11 +1178,23 @@ class WebRTCPeerSession: # `user_id` in the body would let any member of this group read or # overwrite any other member's blob, which is finding C5 one size down. + def _user_blob_refuse(self, detail: str, kind: str = "") -> None: + """ + Refuse, and say so in the audit log. + + A refusal used to be invisible here: the audit line was written only + after a store *succeeded*, so a client whose writes were all being + turned away looked exactly like a client that never wrote — which is + how a wedged sync went unnoticed for two hours. + """ + self._audit("user_blob_refused", f"{kind} {detail}".strip()) + self._send({"type": "error", "detail": detail}) + def _user_blob_kind(self, msg: dict) -> str | None: """The validated `kind`, or None having already refused.""" kind = msg.get("kind") if not isinstance(kind, str) or not _USER_BLOB_KIND_RE.match(kind): - self._send({"type": "error", "detail": "Unknown blob kind"}) + self._user_blob_refuse("Unknown blob kind", str(kind)[:40]) return None return kind @@ -1206,13 +1218,13 @@ class WebRTCPeerSession: blob = msg.get("blob_enc") if not isinstance(blob, (bytes, bytearray)) or not blob: - self._send({"type": "error", "detail": "Missing blob_enc"}) + self._user_blob_refuse("Missing blob_enc", kind) return blob = bytes(blob) rev = msg.get("rev") if not isinstance(rev, int) or rev < 0: - self._send({"type": "error", "detail": "Missing rev"}) + self._user_blob_refuse("Missing rev", kind) return limit = (USER_BLOB_MANIFEST_MAX if kind == "playlists" @@ -1221,8 +1233,7 @@ class WebRTCPeerSession: # A stated reason, not a bare error: the client turns this into a # sentence the reader can act on ("this playlist is too large"), # and a refusal nobody can read is a support case. - self._send({"type": "error", - "detail": f"Blob too large ({len(blob)} > {limit})"}) + self._user_blob_refuse(f"Blob too large ({len(blob)} > {limit})", kind) return # What the account already uses, minus whatever this call replaces. @@ -1231,9 +1242,9 @@ class WebRTCPeerSession: if existing: used -= len(existing["blob_enc"]) if used + len(blob) > USER_BLOB_ACCOUNT_MAX: - self._send({"type": "error", - "detail": f"Account blob quota exceeded " - f"({used + len(blob)} > {USER_BLOB_ACCOUNT_MAX})"}) + self._user_blob_refuse( + f"Account blob quota exceeded " + f"({used + len(blob)} > {USER_BLOB_ACCOUNT_MAX})", kind) return await store.store_user_blob(self._user_id, kind, rev, blob) @@ -1263,6 +1274,7 @@ class WebRTCPeerSession: if not store: return blobs = await store.list_user_blobs(self._user_id) + self._audit("user_blob_list", f"{len(blobs)} blobs") self._send({"type": MNP.USER_BLOB_LIST_RESP, "v": MNP_VERSION, "blobs": blobs}) diff --git a/packages/meshbay-node/tests/test_user_blob_mnp.py b/packages/meshbay-node/tests/test_user_blob_mnp.py index 296239f..85893af 100644 --- a/packages/meshbay-node/tests/test_user_blob_mnp.py +++ b/packages/meshbay-node/tests/test_user_blob_mnp.py @@ -242,6 +242,37 @@ async def test_an_unauthenticated_session_reaches_nothing(store): @pytest.mark.asyncio +@pytest.mark.parametrize("msg, why", [ + ({"kind": "nonsense", "rev": 1, "blob_enc": b"x"}, "Unknown blob kind"), + ({"kind": "playlists", "rev": 1}, "Missing blob_enc"), + ({"kind": "playlists", "blob_enc": b"x"}, "Missing rev"), + ({"kind": "playlists", "rev": 1, + "blob_enc": b"x" * (USER_BLOB_MANIFEST_MAX + 1)}, "too large"), +]) +async def test_a_refusal_is_audited_and_not_merely_sent(store, msg, why): + """A refusal used to leave no trace at all: the audit line was written only + after a store *succeeded*. So a client whose every write was being turned + away looked exactly like a client that never wrote — which is how a wedged + sync went unnoticed, with the operator's own log saying nothing.""" + s = _session(store) + await s._do_user_blob_store(msg) + assert _last(s)["type"] == "error" + events = [(e, d) for e, d in s.audited if e == "user_blob_refused"] + assert events, "the refusal is invisible in the audit log" + assert why in events[0][1] + + +@pytest.mark.asyncio +async def test_a_listing_is_audited(store): + """Half the traffic was invisible: `user_blob_list` is what every sync does + first, and it wrote no audit line, so the log could not distinguish a client + that was syncing from one that was not.""" + s = _session(store) + await s._do_user_blob_list() + assert ("user_blob_list", "0 blobs") in s.audited + + +@pytest.mark.asyncio async def test_reads_and_writes_are_audited(store): """The node logs that a blob moved, never what was in it — the same line `keypair_bundle_store` already writes.""" |