diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-19 10:47:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-19 10:47:28 +0200 |
| commit | 1db49c37ea01db8498694613b3d0e2d54bd0d96d (patch) | |
| tree | cf87baeee829c04c793fe3725d0dff1b4080e6d8 /packages/meshbay-hub/src | |
| parent | e1bce3b8d5835c3c70208d39b5b2c66787625e15 (diff) | |
| download | meshbay-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-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 33 |
1 files changed, 32 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 67e65f2..83b60f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -8,7 +8,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import issue_access_token @@ -89,6 +89,22 @@ class NodeAnnounceRequest(BaseModel): signature: str | None = None # base64 Ed25519 over the announce message +# How many distinct node keys one account may announce. +# +# M8 closed the half of this that was about *whose* key it is: the announcer now +# proves possession. What it did not close is *how many*. Each new key 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 somebody else's disk, having paid +# only for the signatures. +# +# Ten is past what the feature is for. A node is a machine left running: a +# desktop, a laptop, a box in a cupboard, a second home. Someone who genuinely +# reaches it deletes one, which is a thing the operator surface already does — +# and an account that wants an eleventh *identity* rather than an eleventh +# machine is the case this refuses. +MAX_NODES_PER_ACCOUNT = 10 + + @router.post("/announce", status_code=201) async def announce_node( body: NodeAnnounceRequest, @@ -146,6 +162,21 @@ async def announce_node( await db.commit() return {"node_id": node.id} + # Counted only where a row is actually added: re-announcing a key this + # account already holds takes the branch above and must keep working at the + # ceiling, or a node that has reached it can never refresh its address again. + held = (await db.execute( + select(func.count()).select_from(Node) + .where(Node.user_id == current_user.id))).scalar() or 0 + if held >= MAX_NODES_PER_ACCOUNT: + db.add(IPLog(user_id=current_user.id, event="node_announce_refused", + ip_address=seen_from, detail=f"{held} nodes")) + await db.commit() + raise HTTPException( + status_code=409, + detail=f"This account already has {held} nodes, which is the limit of " + f"{MAX_NODES_PER_ACCOUNT}. Remove one you no longer run.") + node = Node( user_id=current_user.id, pk_node=body.pk_node, |