summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-19 10:47:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-19 10:47:28 +0200
commit1db49c37ea01db8498694613b3d0e2d54bd0d96d (patch)
treecf87baeee829c04c793fe3725d0dff1b4080e6d8 /packages/meshbay-node/src
parente1bce3b8d5835c3c70208d39b5b2c66787625e15 (diff)
downloadmeshbay-1db49c37ea01db8498694613b3d0e2d54bd0d96d.tar.gz
fix: the two ceilings §13.5b was still missing, as AV27 and AV28
**A free-text TMDB search spends the operator's credential.** TMDB rates it, and the automatic matching every member sees runs on the same one, so a member holding down the search box — or a script doing it — degrades the library for everyone and empties a quota the operator pays for. The handler had no ceiling of any kind, where link previews beside it carry two. §6.5's standing rule is a bound and a named adversary in the same commit; this arrived with neither. Per member and not per connection, unlike link previews: three tabs is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the group context, so a reconnect does not reset it — a client that drops its channel between searches would otherwise have no ceiling at all. The node-wide window stays too, because the two answer different questions: one keeps a member from spending everyone's quota, the other keeps a roomful of them from doing it together. Ten a minute each, thirty for the node — a search every six seconds, sustained, is past what anyone types. The refusal is an error rather than an empty list. An empty list is what "no such film" looks like, and telling somebody their film is unknown when the node simply declined to ask is a worse answer than the truth; `video-app.js` already puts `detail` on screen. **How many node keys one account may announce.** Each is a row in `nodes` plus a row in the IP log, and the IP log is kept for a year, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. M8 settled whose key it is and said nothing about how many. Ten: a node is a machine left running, and an account wanting an eleventh *identity* rather than an eleventh machine is the case this refuses. Counted only where a row is added. Applied to every announce it would freeze the address of every node an account already runs the moment it reached the limit, and a node that cannot re-announce is unreachable after its ISP renumbers it — an outage caused by the protection. There is a test for exactly that. Both tests are two accounts, per §13.5b: a ceiling one person can exhaust for another is not a ceiling but a queue, and a ceiling shared between accounts would let one member stop every other from bringing a machine online. Checked by removing each ceiling: seven tests fail. `test_season_and_search_requests.py` built its session without a `_user_id`, which production guarantees — `_dispatch_message` refuses every message until the handshake settles it. The fixture was narrower than the node, so it could not exercise a per-member bound at all; it has one now. Twelve `test_sticky_header.py[firefox]` setup errors in a full run here: Firefox is open on this machine, the trap CLAUDE.md describes, and its twelve `[chrome]` tests covering the same geometry pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py59
1 files changed, 59 insertions, 0 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 4540f2f..773d3dc 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -213,6 +213,23 @@ _LINK_PREVIEW_RATE_WINDOW = 60.0
_LINK_PREVIEW_RATE_PER_CONN = 15
_LINK_PREVIEW_RATE_NODE = 60
+# A free-text TMDB search spends the *operator's* credential, which is rated by
+# TMDB and shared by everyone in the group: one member typing in the search box
+# can exhaust what every other member's automatic matching depends on, and the
+# operator is the one who has to notice. §6.5's rule is a bound and a named
+# adversary in the same commit; this one arrived without either.
+#
+# Per member rather than per connection, unlike link previews above: three tabs
+# is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the
+# group context so it survives a reconnect, which is the other thing a per-session
+# count cannot do.
+#
+# Generous next to what a person types — ten searches a minute is a search every
+# six seconds, sustained — and small next to a loop.
+_TMDB_SEARCH_WINDOW = 60.0
+_TMDB_SEARCH_PER_MEMBER = 10
+_TMDB_SEARCH_NODE = 30
+
# Chat limits. A message is a member-supplied write onto the operator's disk
# (`chat.db`, where retention is a manual CLI command — §6.6), relayed from there
# to every other connected member and turned into a notification for every member
@@ -4471,6 +4488,21 @@ class WebRTCPeerSession:
"query": query, "media_type": media_type, "results": []})
return
+ # Refused out loud, not as an empty result: "no matches" is what the
+ # client draws for an empty list, and telling somebody their film is
+ # unknown when the node simply declined to ask is a worse answer than
+ # the truth. `video-app.js`'s `runSearch` puts `detail` on screen.
+ if not self._tmdb_search_rate_ok():
+ log.info("tmdb_search_req: rate-limited (user=%s)", (self._user_id or "")[:8])
+ self._send({
+ "type": "error",
+ "detail": "Too many searches in the last minute. This spends the "
+ "operator's search quota, which everyone in the group "
+ "shares — try again shortly.",
+ "code": "tmdb_search_rate_limited",
+ })
+ return
+
raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
else await tmdb_client.search_tv_results(query))
results = []
@@ -5103,6 +5135,33 @@ class WebRTCPeerSession:
if isinstance(m.payload, bytes) else m.payload)
return row
+ def _tmdb_search_rate_ok(self) -> bool:
+ """
+ True when this search is within both the member's window and the node's;
+ records it when so, and trims both to the window on every call so neither
+ list can grow without bound.
+
+ Both are checked because they answer different questions: the member's
+ keeps one person from spending everyone's quota, and the node's keeps a
+ group of them from doing it together.
+ """
+ now = time.monotonic()
+ w = _TMDB_SEARCH_WINDOW
+ ctx = self._group_ctx()
+ by_member = ctx.setdefault("tmdb_search_hits", {})
+ who = self._user_id or ""
+ mine = [t for t in by_member.get(who, []) if now - t < w]
+ node = [t for t in self._ctx.get("tmdb_search_hits_node", []) if now - t < w]
+ if len(mine) >= _TMDB_SEARCH_PER_MEMBER or len(node) >= _TMDB_SEARCH_NODE:
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return False
+ mine.append(now)
+ node.append(now)
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return True
+
def _link_preview_rate_ok(self) -> bool:
"""
True when this preview fetch is within both the per-connection and the