aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py6
2 files changed, 35 insertions, 4 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,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 7b2fc8c..0394f53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -107,7 +107,7 @@ class RegisterRequest(BaseModel):
email: str
password: str | None = None # deprecated — legacy native clients
auth_key: str | None = None # PBKDF2-derived, new clients
- # Client-generated account recovery key (docs/auth-confirm.md §4.4). Pure
+ # Client-generated account recovery key (docs/MESHBAY_DESIGN.md §3.6). Pure
# pass-through: appended to the verification e-mail so the user's mailbox
# backs it up, then dropped. Never written to any table, never logged.
recovery_key: str | None = None
@@ -901,7 +901,7 @@ async def verify_email_change(
# ── Passphrase change (Flow A) ──────────────────────────────────────────────
#
-# docs/auth-confirm.md §3. The passphrase derives two independent values on the
+# docs/MESHBAY_DESIGN.md §3.6. The passphrase derives two independent values on the
# client: auth_key (verified here) and bundle_key (AES-GCM key for the per-node
# identity bundles, which live on nodes and never on the hub). The client
# re-wraps those bundles from the old bundle_key to the new one on every
@@ -1023,7 +1023,7 @@ async def change_password(
# ── Passphrase reset (Flow B) ──────────────────────────────────────────────
#
-# docs/auth-confirm.md §4.2. An e-mail code re-opens hub login for someone who
+# docs/MESHBAY_DESIGN.md §3.6. An e-mail code re-opens hub login for someone who
# has lost their passphrase. It recovers no group content — that needs the
# recovery key, which the client applies on its own after the reset.
# reset-request never reveals whether an account exists.