aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-28 21:35:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-28 21:35:20 +0200
commit91505face56f7ee6817408e52bad7902add75f09 (patch)
tree9902490872f441c5c88ea1f7ab57288ab6507899
parenta421a03d2be16670dc8d9076d26f4a7eac669986 (diff)
downloadmeshbay-91505face56f7ee6817408e52bad7902add75f09.tar.gz
refactor: remove the public-content swarm
Nodes registered the hashes of their public groups on the hub and nothing ever read them back. Routes, model and node registration removed; a migration drops swarm_sources. No node sends the hub a content hash now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py108
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py16
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py20
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py50
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py28
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py22
-rw-r--r--packages/meshbay-node/tests/test_daemon.py74
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py21
12 files changed, 64 insertions, 336 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 07aef05..85e436b 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -128,8 +128,9 @@ else about content.
This is decision **E9**, and it is the rule any new feature is measured against.
A feature that wants a row on the hub about a group's content is a feature that
has misunderstood the model. It has been re-verified at each content-model change:
-`SwarmSource` carries a content hash, a node id and an endpoint — **no paths, no
-filenames** — and private groups register nothing at all (**H7**).
+**no node registers a content hash with the hub**, for any group (**H7**); the only
+hashes the hub holds are those of public content somebody reported (§7.5) — **no
+paths, no filenames**.
---
@@ -520,8 +521,7 @@ group key.** That is a property of open joining, not a defect of this design.
Content in such a group is protected from the network and from non-members, and
from nobody else.
-Note the axis. **`visibility`** (public/private) controls discoverability and swarm
-hash registration (**H7**). **`join_policy`** (open/request/invite) controls
+Note the axis. **`visibility`** (public/private) controls discoverability. **`join_policy`** (open/request/invite) controls
admission. Only the second decides whether a code is required: a public group with
`join_policy = "invite"` keeps the code, because being findable is not being open.
@@ -2027,7 +2027,7 @@ radius is proof of the passphrase. An admin can delete one too.
The row is **tombstoned rather than dropped**: username released, email and password
hash cleared, node linking key dropped, memberships, notifications, refresh tokens,
-node registrations, device keys and public-swarm sources removed, active tokens
+node registrations and device keys removed, active tokens
refused at once by a status check rather than left to expire. Device keys go
because the desktop client keeps its half: left on the tombstone, the key would
refuse that installation to the next account created from it.
@@ -3143,7 +3143,7 @@ be understood, not so the incident can be retold.
| **H4** | Revocation reaches nodes, drops live sessions, and **persists across a restart** (§7.5) |
| **H5** | An admin challenge is a **structured, domain-separated transcript naming the operation and subject**, and the client refuses to sign anything that is not what the user asked for (§5.4) |
| **H6** | Unauthenticated work a node will do is bounded: a small pre-handshake buffer, a transcode semaphore, per-user pending-offer caps, and a membership check on signaling (§7.2) |
-| **H7** | **Only public groups register content hashes with the hub.** Private groups register nothing, and the swarm route requires authentication |
+| **H7** | **No node registers a content hash with the hub**, for any group. The only content hashes it holds are of public content somebody reported (§7.5) |
**Medium**
@@ -3252,7 +3252,7 @@ had already been asked.
| **AV1** | **An empty claim is a claim on nothing.** A node's group set is `authorized ∩ claimed`, and an absent or empty `group_ids` registers it for no group rather than all of its owner's — on registration and on `update_groups` alike (§7.2) |
| **AV2** | **A client treats the hub's node list as candidates, not a ranking**, and tries the next one on a `not_hosted` refusal (`MESHBAY_NODE_PROTOCOL.md` §6.3) |
| **AV3** | **A node speaks only for the groups it is registered for.** `chat_notify` names a group and is checked against that node's set before a notification is written for anyone, and it is rate-limited per node — the fan-out is one write per member |
-| **AV4** | **Nobody names a third party's address.** A swarm source publishes a transport and a port, never a host; where a peer is comes from its node record, stamped with the address its announce arrived from. The number of hashes one account may claim is bounded |
+| **AV4** | **Nobody names a third party's address.** Where a peer is comes from its node record, stamped with the address its announce arrived from |
| **AV5** | **An answer is accepted only from the node the offer was sent to.** A `peer_id` is bound to its node, so no connected node can resolve another's pending offer |
| **AV6** | **A relay proves possession of its approved key.** A public key is not a password, and the register call is unauthenticated by design — it is not a user — so the proof is the only thing standing between a stranger and where nodes send relayed traffic |
| **AV7** | **A node bounds how many peers it holds and how long an unproven one lasts.** The hub's cap is per calling account, which is a limit on each member and not on the machine, so without this an operator's exposure grew with the size of their groups |
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 52d9f60..df2f336 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -20,16 +20,11 @@ from meshbay_hub.db.models import (
Group,
GroupMember,
IPLog,
- SwarmSource,
User,
)
router = APIRouter(prefix="/v1/groups", tags=["groups"])
-# Swarm endpoints live at /v1/swarm/*. They were previously declared on the groups
-# router with a full path, which mounted them at /v1/groups/v1/swarm/* (H7).
-swarm_router = APIRouter(prefix="/v1/swarm", tags=["swarm"])
-
@router.get("/mine")
async def my_groups(
@@ -211,109 +206,6 @@ async def list_public_groups(
return {"groups": groups, "total": len(groups)}
-# ── Swarm (content replication) ───────────────────────────────────────────────
-
-class SwarmRegisterRequest(BaseModel):
- content_hash: str # blake3 hex
- endpoint: str # "<scheme>:<port>" — a port on the caller, never a host
-
-
-# A transport and a port, and deliberately no host. The field used to be free
-# text documented as "ip:port", so a caller could name *someone else's*
-# address as a source; nothing dials a swarm source today, which is the only
-# reason that was not already a reflection primitive. A reader learns where a
-# node is from the node record, which is stamped with the address the announce
-# actually came from — so a host here would be a second, weaker, answer to a
-# question already settled elsewhere.
-_SWARM_ENDPOINT = re.compile(r"^(webrtc|quic):([0-9]{1,5})$")
-
-# One account, this many public hashes. Rows are keyed (hash, account) with no
-# cap, so a loop of invented hashes was unbounded storage growth on a hub
-# shared with everyone else. A public library far larger than this is a real
-# thing — but it is one a hub operator should be asked about, not something a
-# client establishes by writing rows.
-MAX_SWARM_HASHES_PER_ACCOUNT = 10_000
-
-
-@swarm_router.post("/register", status_code=201)
-@limiter.limit("120/minute")
-async def swarm_register(
- body: SwarmRegisterRequest,
- request: Request,
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
-):
- """
- Node registers itself as a source for a PUBLIC content hash.
-
- Finding H7: the node registered hashes for every group it hosted, private ones
- included, and this route was mounted at /v1/groups/v1/swarm/register — so the
- node's calls 404'd and the leak was masked by a routing bug rather than
- prevented. Nodes now filter by group visibility before calling, and the path is
- correct, so the filter has to be right.
-
- Availability: the endpoint is a port, not an address, and the number of
- hashes one account may claim is bounded. See the two constants above.
- """
- m = _SWARM_ENDPOINT.match(body.endpoint or "")
- if not m or not (0 < int(m.group(2)) < 65536):
- raise HTTPException(
- status_code=422,
- detail="endpoint must be '<webrtc|quic>:<port>' — a port on the "
- "registering node, not an address")
-
- from datetime import datetime
- existing = await db.get(SwarmSource, (body.content_hash, current_user.id))
- now = datetime.now(UTC)
- if existing:
- existing.endpoint = body.endpoint
- existing.last_seen = now
- else:
- held = (await db.execute(
- select(func.count()).select_from(SwarmSource)
- .where(SwarmSource.node_id == current_user.id))).scalar() or 0
- if held >= MAX_SWARM_HASHES_PER_ACCOUNT:
- raise HTTPException(
- status_code=429,
- detail="This account already claims the maximum number of "
- "public content hashes")
- db.add(SwarmSource(
- content_hash=body.content_hash,
- node_id=current_user.id,
- endpoint=body.endpoint,
- ))
- await db.commit()
- return {"status": "registered", "hash": body.content_hash}
-
-
-@swarm_router.get("/{content_hash}")
-async def swarm_sources(
- content_hash: str,
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
-):
- """
- Return nodes that can serve a content hash.
-
- Authenticated (H7): an open endpoint lets anyone probe whether a given file
- exists anywhere in the network and which node holds it.
- """
- from datetime import datetime, timedelta
- cutoff = datetime.now(UTC) - timedelta(minutes=30)
- result = await db.execute(
- select(SwarmSource)
- .where(
- SwarmSource.content_hash == content_hash,
- SwarmSource.last_seen > cutoff,
- )
- )
- sources = result.scalars().all()
- return {
- "hash": content_hash,
- "sources": [{"node_id": s.node_id, "endpoint": s.endpoint} for s in sources],
- }
-
-
@router.get("/{group_id}/members")
async def group_members(
group_id: str,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 7046c2f..3e996a7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -41,7 +41,6 @@ from meshbay_hub.db.models import (
Node,
Notification,
RefreshToken,
- SwarmSource,
User,
UserDevice,
UserPreference,
@@ -1379,14 +1378,13 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
the person it is about.
Gone: credentials, email, node key, group memberships, notifications, refresh
- tokens, node registrations, device keys, public-swarm sources. The username
+ tokens, node registrations, device keys. The username
is released.
Device keys go even though the desktop client keeps its private half: left
behind, the key still belongs to this tombstone, so an account created later
from the same installation is refused that device ("belongs to another
- account"). Swarm sources are keyed by the *user* id and carry the node's
- ip:port.
+ account").
Kept: the row itself, emptied, and the IP log that points at it. Those logs
exist for one year to answer legal requests, and a log that cannot say whose
@@ -1419,7 +1417,6 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id))
await db.execute(delete(Node).where(Node.user_id == user.id))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
- await db.execute(delete(SwarmSource).where(SwarmSource.node_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
# Links this account issued for a group it no longer owns; the ones for its
# own groups went with them above. A used link keeps pointing at the
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index cca1e6b..29de118 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -21,7 +21,6 @@ from meshbay_hub.api.admin import router as admin_router
from meshbay_hub.api.deps import set_admin_usernames
from meshbay_hub.api.federation import router as federation_router
from meshbay_hub.api.groups import router as groups_router
-from meshbay_hub.api.groups import swarm_router
from meshbay_hub.api.health import router as health_router
from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.hub import set_config as hub_set_config
@@ -199,7 +198,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(groups_router)
app.include_router(invite_links_router)
app.include_router(invite_links_redeem_router)
- app.include_router(swarm_router)
app.include_router(revocation_router)
app.include_router(moderation_router)
app.include_router(federation_router)
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py
new file mode 100644
index 0000000..0e61390
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py
@@ -0,0 +1,38 @@
+"""drop the public-content swarm table
+
+Nodes registered the hashes of their public groups here and nothing ever read
+them back: the swarm was written and never used.
+
+Revision ID: e6f7a8b9c0d1
+Revises: c4d5e6f7a8b9
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "e6f7a8b9c0d1"
+down_revision: str | Sequence[str] | None = "c4d5e6f7a8b9"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.drop_index("ix_swarm_hash", table_name="swarm_sources")
+ op.drop_table("swarm_sources")
+
+
+def downgrade() -> None:
+ op.create_table(
+ "swarm_sources",
+ sa.Column("content_hash", sa.String(64), nullable=False),
+ sa.Column("node_id", sa.String(36), nullable=False),
+ sa.Column("endpoint", sa.String(128), nullable=False),
+ sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False,
+ server_default=sa.func.now()),
+ sa.Column("last_seen", sa.DateTime(timezone=True), nullable=False,
+ server_default=sa.func.now()),
+ sa.PrimaryKeyConstraint("content_hash", "node_id"),
+ )
+ op.create_index("ix_swarm_hash", "swarm_sources", ["content_hash"])
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 09eb437..288f1e7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -266,22 +266,6 @@ class UserDevice(Base):
__table_args__ = (Index("ix_user_devices_user", "user_id"),)
-class SwarmSource(Base):
- """
- Tracks which nodes can serve a given content hash (public swarm).
- Hub maintains this for load-balanced public content delivery.
- """
- __tablename__ = "swarm_sources"
-
- content_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
- node_id: Mapped[str] = mapped_column(String(36), primary_key=True)
- endpoint: Mapped[str] = mapped_column(String(128), nullable=False)
- registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
- last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
-
- __table_args__ = (Index("ix_swarm_hash", "content_hash"),)
-
-
class Notification(Base):
__tablename__ = "notifications"
diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py
index 2653f0d..64c2be8 100644
--- a/packages/meshbay-hub/tests/test_account_deletion.py
+++ b/packages/meshbay-hub/tests/test_account_deletion.py
@@ -130,16 +130,9 @@ def _device_pk() -> str:
@pytest.mark.asyncio
-async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session):
- """
- The privacy statement says every account row goes but the IP log. Swarm
- sources are keyed by the *user* id despite the column's name, and carry the
- node's transport and port — `webrtc:<port>`, which is what `daemon.py`
- actually sends. This asked with `192.0.2.7:4433`, from the days when the
- field was free text documented as "ip:port": a shape no node has ever
- produced, and one that let a caller name a third party's address.
- """
- from meshbay_hub.db.models import SwarmSource, UserDevice
+async def test_deletion_clears_device_keys(client, db_session):
+ """The privacy statement says every account row goes but the IP log."""
+ from meshbay_hub.db.models import UserDevice
token, password = await _register(client, "devicer_test")
headers = {"Authorization": f"Bearer {token}"}
@@ -149,15 +142,10 @@ async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session)
r = await client.post("/v1/users/devices", headers=headers,
json={"pk_auth_ed25519": _device_pk(), "label": "desktop"})
assert r.status_code == 201, r.text
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": "ab" * 32, "endpoint": "webrtc:4433"})
- assert r.status_code == 201, r.text
# Present before, or the emptiness asserted below proves nothing.
assert (await db_session.execute(
select(UserDevice).where(UserDevice.user_id == uid))).scalars().all()
- assert (await db_session.execute(
- select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all()
r = await client.request("DELETE", "/v1/users/me", headers=headers,
json={"auth_key": _auth_key(password, "devicer_test")})
@@ -166,8 +154,6 @@ async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session)
db_session.expire_all()
assert (await db_session.execute(
select(UserDevice).where(UserDevice.user_id == uid))).scalars().all() == []
- assert (await db_session.execute(
- select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all() == []
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index 24d85a0..2773d87 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -202,56 +202,6 @@ async def test_the_notify_budget_is_not_refilled_by_reconnecting(client):
rev._notify_window.pop(node_id, None)
-# ── A member must not aim other people's traffic ─────────────────────────────
-
-@pytest.mark.asyncio
-async def test_a_swarm_source_cannot_name_someone_elses_address(client):
- """
- `endpoint` was free text documented as "ip:port", so an account could
- publish a third party's address as a source for any content. Nothing dials
- a swarm source today, which is the only reason this was not already the
- reflection primitive that `notify_incoming` was fixed for (H6). A port is
- all a reader needs: where the node is comes from the node record, which is
- stamped with the address its announce arrived from.
- """
- user = await _make_user(client, "av_swarm1")
- headers = {"Authorization": f"Bearer {user['token']}"}
-
- for bad in ("192.0.2.7:4433", "evil.example:53", "webrtc:0", "webrtc:70000",
- "webrtc:4433 ", "http://example.test"):
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": "ab" * 32, "endpoint": bad})
- assert r.status_code == 422, f"{bad!r} was accepted: {r.text}"
-
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": "ab" * 32, "endpoint": "webrtc:19010"})
- assert r.status_code == 201, r.text
-
-
-@pytest.mark.asyncio
-async def test_one_account_cannot_fill_the_swarm_table(client, monkeypatch):
- """Rows are keyed (hash, account) with no cap — an invented hash each time."""
- import meshbay_hub.api.groups as groups_api
- monkeypatch.setattr(groups_api, "MAX_SWARM_HASHES_PER_ACCOUNT", 3)
-
- user = await _make_user(client, "av_swarm2")
- headers = {"Authorization": f"Bearer {user['token']}"}
- for i in range(3):
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": f"{i:064x}",
- "endpoint": "webrtc:19010"})
- assert r.status_code == 201, r.text
-
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": f"{99:064x}", "endpoint": "webrtc:19010"})
- assert r.status_code == 429, r.text
-
- # Refreshing one already held is not a new claim and must still work.
- r = await client.post("/v1/swarm/register", headers=headers,
- json={"content_hash": f"{0:064x}", "endpoint": "webrtc:19011"})
- assert r.status_code == 201, r.text
-
-
# ── A member's node must not answer for another's ────────────────────────────
def test_a_node_cannot_answer_an_offer_it_was_never_sent():
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 220a908..a29aa9f 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -667,12 +667,6 @@ class NodeDaemon(EnrichmentMixin):
await indexer.initial_scan()
log.info("Background scan complete for %s: %d files",
name, indexer.index.count)
- # Swarm registration for public groups (after files are known).
- if gctx.get("visibility") == "public":
- endpoint = f"webrtc:{self._config.node.quic_port}"
- hashes = [e.id for e in gctx["index"].entries]
- if hashes:
- await self._register_swarm(hashes, endpoint)
# initial_scan() itself never calls on_change (it predates
# the concept — every existing caller only cared about the
# scan finishing, not about notifying anyone) — but Videos
@@ -1468,21 +1462,6 @@ class NodeDaemon(EnrichmentMixin):
log.info("Index %s pushed to %d WebRTC peers",
"delta" if delta is not None else "sync", pushed)
- # 11.9 — Register file hashes with hub swarm table (public groups only, H7)
- group_cfg = next(
- (g for g in self._config.groups if g.id == group_id), None)
- if (self._hub and self._state.get("endpoint_hint")
- and group_cfg and group_cfg.visibility == "public"):
- # Only the newly added hashes once there is a delta to know them
- # from — registering the whole library again on every change is
- # the same O(changes x library size) cost the delta above exists
- # to avoid.
- hashes = ([e.id for e in delta.additions] if delta is not None
- else [e.id for e in idx.entries])
- if hashes:
- endpoint = f"webrtc:{self._config.node.quic_port}"
- spawn(self._register_swarm(hashes, endpoint))
-
def _drop_group_sessions(self, group_id: str) -> None:
"""Close live sessions for a revoked group (H4)."""
if not self._webrtc or not group_id:
@@ -1492,13 +1471,6 @@ class NodeDaemon(EnrichmentMixin):
spawn(session.close())
log.info("Dropped session for revoked group %s", group_id[:8])
- async def _register_swarm(self, hashes: list[str], endpoint: str) -> None:
- try:
- n = await self._hub.register_swarm(hashes, endpoint)
- log.info("Swarm: registered %d/%d hashes", n, len(hashes))
- except Exception as e:
- log.warning("Swarm registration failed: %s", e)
-
async def _shutdown(self) -> None:
log.info("Shutting down...")
self._state["status"] = "stopping"
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index 346a4cd..5f47090 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -6,7 +6,6 @@ Handles all communication from the node to a Mesh Hub:
- JWT offline verification and auto-refresh
- Node announcement (endpoint_hint)
- User public key lookup (for GEK wrapping)
- - Swarm hash registration
The node authenticates via Ed25519 challenge-response (/v1/nodes/auth).
No auth_key or password is ever stored on or transmitted from the node.
@@ -461,27 +460,6 @@ class HubClient:
log.warning("Could not deliver WebRTC answer to %s: %s",
str(msg.get("peer_id"))[:8], e)
- # ── Swarm registration ─────────────────────────────────────────────────
-
- async def register_swarm(self, content_hashes: list[str], endpoint: str) -> int:
- """Register file hashes in the hub swarm table. Returns count registered."""
- if self._session is None:
- raise RuntimeError("Not logged in")
- await self.ensure_fresh_token()
-
- registered = 0
- for h in content_hashes:
- try:
- r = await self._http.post("/v1/swarm/register", json={
- "content_hash": h,
- "endpoint": endpoint,
- }, headers=self._session.auth_headers)
- if r.status_code in (201, 200):
- registered += 1
- except Exception:
- pass
- return registered
-
# ── Convenience: full startup sequence ───────────────────────────────────
async def startup(self, endpoint_hint: str | None = None) -> HubSession:
diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py
index 8c4da2d..acaafac 100644
--- a/packages/meshbay-node/tests/test_daemon.py
+++ b/packages/meshbay-node/tests/test_daemon.py
@@ -2,7 +2,7 @@
Integration test: Node daemon wires all components correctly.
Phase 11 — verifies that NodeDaemon creates chat stores, WebRTC transport,
-index push on change, swarm registration, and shuts down cleanly.
+index push on change, and shuts down cleanly.
Hub interaction is mocked.
"""
@@ -241,7 +241,6 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu
daemon = NodeDaemon(config)
daemon._broadcast_coalesce_secs = 0.01 # real value would make this test wait 0.5s
daemon._hub = AsyncMock()
- daemon._hub.register_swarm = AsyncMock(return_value=2)
daemon._state["endpoint_hint"] = "node123"
sk_node = Ed25519PrivateKey.generate()
@@ -268,47 +267,10 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu
payload = unseal(gek, PURPOSE_INDEX, "index_sync", "a" * 32, msg)
assert len(payload["entries"]) == indexer.index.count
- # Finding H7: this group is private, so its content hashes must NOT be
- # registered with the hub. The test previously asserted the opposite —
- # publishing a fingerprint of every private file was treated as expected
- # behaviour. Index push to members is unaffected (asserted above).
+ # Finding H7: a change to the index tells the hub nothing — no content hash
+ # of any group reaches it. Index push to members is unaffected (above).
await asyncio.sleep(0.1)
- daemon._hub.register_swarm.assert_not_called()
-
-@pytest.mark.asyncio
-async def test_daemon_index_change_registers_swarm_for_public_group(
- tmp_path, shared_dir, gek, hub_pk_pem):
- """Public groups still register content hashes with the hub swarm (H7)."""
- config = Config(
- hub=HubConfig(url="http://localhost:9999", username="testuser"),
- node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
- groups=[GroupConfig(
- id="a" * 32,
- name="public-group",
- shared_dir=str(shared_dir),
- visibility="public",
- quic_port=29010,
- )],
- keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
- data_dir=tmp_path / "data",
- )
- daemon = NodeDaemon(config)
- daemon._broadcast_coalesce_secs = 0.01
- daemon._hub = AsyncMock()
- daemon._hub.register_swarm = AsyncMock(return_value=2)
- daemon._state["endpoint_hint"] = "node123"
-
- indexer = DirectoryIndexer(
- roots=one_root(shared_dir), group_id="a" * 32,
- sk_node=Ed25519PrivateKey.generate(), gek=gek)
- await indexer.initial_scan()
-
- await daemon._on_index_change(indexer)
-
- await asyncio.sleep(0.1)
- daemon._hub.register_swarm.assert_called_once()
- assert len(daemon._hub.register_swarm.call_args[0][0]) == indexer.index.count
-
+ assert daemon._hub.mock_calls == []
@pytest.mark.asyncio
async def test_daemon_index_change_skips_other_group_peers(
@@ -325,7 +287,6 @@ async def test_daemon_index_change_skips_other_group_peers(
daemon = NodeDaemon(config)
daemon._broadcast_coalesce_secs = 0.01
daemon._hub = AsyncMock()
- daemon._hub.register_swarm = AsyncMock(return_value=0)
daemon._state["endpoint_hint"] = "node123"
sk_node = Ed25519PrivateKey.generate()
@@ -370,7 +331,6 @@ def _new_daemon_for_group(tmp_path, shared_dir, gek, group_id="a" * 32,
daemon = NodeDaemon(config)
daemon._broadcast_coalesce_secs = 0.01
daemon._hub = AsyncMock()
- daemon._hub.register_swarm = AsyncMock(return_value=0)
daemon._state["endpoint_hint"] = "node123"
return daemon
@@ -532,29 +492,3 @@ async def test_a_burst_of_changes_produces_one_broadcast(tmp_path, shared_dir, g
await asyncio.sleep(0.05)
session._send.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_swarm_registration_only_sends_new_hashes_after_the_first(
- tmp_path, shared_dir, gek):
- daemon = _new_daemon_for_group(tmp_path, shared_dir, gek, visibility="public")
- indexer = DirectoryIndexer(
- roots=one_root(shared_dir), group_id="a" * 32,
- sk_node=Ed25519PrivateKey.generate(), gek=gek)
- await indexer.initial_scan()
- total_files = indexer.index.count
-
- await daemon._on_index_change(indexer)
- await asyncio.sleep(0.05)
- assert len(daemon._hub.register_swarm.call_args_list[0].args[0]) == total_files
-
- from meshbay_common.protocol import IndexEntry
- indexer.index.add_entry(IndexEntry(id="new-file-id", name="new.mp4",
- path="shared", size=10, type="video",
- added_at=0))
- await daemon._on_index_change(indexer)
- await asyncio.sleep(0.05)
-
- assert daemon._hub.register_swarm.call_count == 2
- assert daemon._hub.register_swarm.call_args_list[1].args[0] == ["new-file-id"], \
- "only the newly added hash must be (re-)registered, not the whole library"
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index c0c2c2c..43e88c2 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -602,19 +602,18 @@ def test_denylist_persists_and_honours_groups(tmp_path):
assert not reloaded.is_denied("someone", "other", "g-allowed")
-def test_swarm_registration_skips_private_groups():
+def test_the_node_registers_no_content_hash_with_the_hub():
"""
- H7: the daemon registered content hashes for every group, private included,
- handing the hub a fingerprint of every private file. The bug was masked by a
- mis-mounted route, so fixing the route without this filter would have turned a
- dormant leak into a live one.
+ H7: the daemon once registered content hashes for every group, private
+ included, handing the hub a fingerprint of every private file. The swarm that
+ received them is gone, so no group's hashes are sent to the hub at all.
"""
- source = daemon_source()
- assert 'visibility' in source and '_register_swarm' in source
- # Both registration sites must gate on public visibility.
- for marker in ['gctx.get("visibility") == "public"',
- 'group_cfg.visibility == "public"']:
- assert marker in source, f"swarm registration not gated: {marker}"
+ from pathlib import Path
+
+ import meshbay_node.hub_client as hub_client
+ for source in (daemon_source(), Path(hub_client.__file__).read_text(encoding="utf-8")):
+ assert "/v1/swarm" not in source
+ assert "register_swarm" not in source
def test_keystore_argon2_is_production_strength():