summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py154
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f8a9b0c1d2e3_node_keys.py63
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js54
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js157
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css42
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py1
-rw-r--r--packages/meshbay-hub/tests/test_node_auth.py6
-rw-r--r--packages/meshbay-hub/tests/test_node_keys_several.py440
-rw-r--r--packages/meshbay-hub/tests/test_node_link_banner.py84
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py8
-rw-r--r--packages/meshbay-hub/tests/test_sidebar_node_section.py4
30 files changed, 1154 insertions, 267 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 56af023..36c3c64 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import decode_access_token
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import AdminPin, User
+from meshbay_hub.db.models import AdminPin, NodeKey, User
log = logging.getLogger(__name__)
@@ -106,10 +106,31 @@ async def get_current_user(
if user.status != "active":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail=f"Account {user.status}")
+ if not await node_token_key_linked(db, payload):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Node key no longer linked to this account",
+ headers={"WWW-Authenticate": "Bearer"})
await _pin_if_listed(db, user)
return user
+async def node_token_key_linked(db: AsyncSession, payload: dict) -> bool:
+ """False for a node token whose key the account has since unlinked.
+
+ A node token names the key that signed in (`nk`), so unlinking one machine
+ ends that machine's tokens at once and no other. A node token without `nk`
+ predates the claim; none outlives the hour `/v1/nodes/auth` gives it, and
+ refusing it would strand every node mid-hour on the deploy that adds this,
+ since a node only signs in again when its token is about to expire.
+ """
+ if payload.get("scope") != "node" or "nk" not in payload:
+ return True
+ row = await db.execute(
+ select(NodeKey.id).where(NodeKey.user_id == payload.get("sub", ""),
+ NodeKey.pk_node_ed25519 == payload["nk"]))
+ return row.first() is not None
+
+
def _reject_node_scope(payload: dict) -> None:
"""Refuse a node-scoped daemon token on a route meant for a person.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index b35f11e..367b37f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -16,7 +16,7 @@ from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
from meshbay_hub.auth import issue_access_token, issue_mnp_token
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import GroupMember, IPLog, Node, User
+from meshbay_hub.db.models import GroupMember, IPLog, Node, NodeKey, User
router = APIRouter(prefix="/v1/nodes", tags=["nodes"])
@@ -72,6 +72,21 @@ async def mnp_token(
NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds
+def _signing_key(linked: list[str], message: bytes, signature_b64: str) -> str | None:
+ """The linked key that signed `message`, or None."""
+ try:
+ sig = base64.b64decode(signature_b64)
+ except Exception:
+ return None
+ for pk_b64 in linked:
+ try:
+ Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_b64)).verify(sig, message)
+ return pk_b64
+ except (InvalidSignature, ValueError):
+ continue
+ return None
+
+
class NodeAuthRequest(BaseModel):
username: str
timestamp: int # unix epoch seconds
@@ -97,29 +112,34 @@ async def node_auth(
if user.status != "active":
raise HTTPException(status_code=403, detail=f"Account {user.status}")
- if not user.pk_node_ed25519:
+ linked = (await db.execute(
+ select(NodeKey.pk_node_ed25519).where(NodeKey.user_id == user.id))).scalars().all()
+ if not linked:
raise HTTPException(
status_code=401,
detail="No node key registered — link your node from the browser first",
)
+ # Whichever linked key signed it: one per machine the person runs a node on.
+ # A key that is not linked gets the same "No node key" answer as an account
+ # with none, which is what the daemon reads to wait for its link
+ # (`daemon._login_with_retry`) rather than for an account.
message = f"meshbay:node_auth:{body.username}:{body.timestamp}".encode()
- try:
- pk_raw = base64.b64decode(user.pk_node_ed25519)
- pk = Ed25519PublicKey.from_public_bytes(pk_raw)
- sig = base64.b64decode(body.signature)
- pk.verify(sig, message)
- except (InvalidSignature, Exception):
+ signer = _signing_key(linked, message, body.signature)
+ if signer is None:
db.add(IPLog(event="node_auth_fail", ip_address=client_ip(request), detail=body.username))
await db.commit()
- raise HTTPException(status_code=401, detail="Invalid signature")
+ raise HTTPException(
+ status_code=401,
+ detail="No node key registered for this node — link it from the browser first",
+ )
memberships = await db.execute(
select(GroupMember.group_id).where(GroupMember.user_id == user.id))
group_ids = [gid for (gid,) in memberships.all()]
access_token = issue_access_token(
- user.id, ttl=3600, groups=group_ids, scope="node")
+ user.id, ttl=3600, groups=group_ids, scope="node", node_key=signer)
db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request)))
await db.commit()
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 2499374..c03d582 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -39,7 +39,7 @@ from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.api.deps import get_current_user, require_admin
+from meshbay_hub.api.deps import get_current_user, node_token_key_linked, require_admin
from meshbay_hub.auth import decode_access_token
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
@@ -116,6 +116,18 @@ def forget_node(node_id: str) -> None:
_node_users.pop(node_id, None)
+async def disconnect_node(node_id: str, detail: str) -> None:
+ """Close a node's live socket now, with the reason it is given."""
+ ws = _connected_nodes.get(node_id)
+ if ws is None:
+ return
+ forget_node(node_id)
+ try:
+ await _reject(ws, detail, 4003)
+ except Exception:
+ pass # already gone; it is forgotten either way
+
+
def _spend(window: dict[str, tuple[float, int]], node_id: str, burst: int) -> bool:
"""True if this node may send one more message of a budgeted kind now."""
now = time.monotonic()
@@ -429,6 +441,16 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup
if user is None or user.status != "active":
return None, "Account not active"
+ # The token names the key that signed in (`nk`): it registers that
+ # machine's node and no other, and only while the key is still linked.
+ # A token from before the claim names no key, so the node's own key is
+ # checked instead: unlinking a node closes its socket for good, not
+ # until a token issued before the upgrade runs out.
+ if "nk" in decoded and node.pk_node != decoded["nk"]:
+ return None, "node_id is not the node this token was issued to"
+ if not await node_token_key_linked(db, {**decoded, "nk": node.pk_node}):
+ return None, "Node key no longer linked to this account"
+
# Groups come from the database. The node may narrow the set to what it
# actually hosts, but it cannot widen it past what its account belongs to
# (C2) — nor, within that, past what its account owns or the owner approved.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index fbba6d7..904d7a0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -13,10 +13,11 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, func, select, update
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import hub_settings, login_throttle, mail
-from meshbay_hub.api.deps import get_current_user, require_user_scope
+from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
from meshbay_hub.auth import (
@@ -45,6 +46,7 @@ from meshbay_hub.db.models import (
IPLog,
KnownBrowser,
Node,
+ NodeKey,
Notification,
PushSubscription,
RefreshToken,
@@ -1491,36 +1493,136 @@ class NodeKeyRequest(BaseModel):
pk_node_ed25519: str # base64 raw 32B Ed25519 public key
-@router.put("/me/node_key")
-async def register_node_key(
- body: NodeKeyRequest,
- current_user: User = Depends(require_user_scope),
- db: AsyncSession = Depends(get_db),
-):
- """Link a node daemon's Ed25519 public key to the operator's account."""
+def _node_key(value: str) -> str:
+ """The key in the one spelling `/v1/nodes/announce` and `/auth` compare against."""
try:
- raw = base64.b64decode(body.pk_node_ed25519)
+ raw = base64.b64decode(value.strip(), validate=True)
if len(raw) != 32:
raise ValueError
except Exception:
raise HTTPException(
status_code=400,
detail="Invalid Ed25519 public key (need 32 bytes base64)")
+ return base64.b64encode(raw).decode()
- current_user.pk_node_ed25519 = body.pk_node_ed25519
- await db.commit()
- return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519}
+
+@router.get("/me/node_keys")
+async def list_node_keys(
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """The node keys linked to your account, oldest first, with what the hub saw of each node.
+
+ `last_seen` and `observed_ip` come from that node's own announce, so they are
+ empty for a key that has never signed in. `connected` says whether it holds
+ its socket to the hub right now. Your own account only: another account's
+ keys are never listed anywhere.
+ """
+ from meshbay_hub.api.nodes import MAX_NODES_PER_ACCOUNT
+ from meshbay_hub.api.revocation import _connected_nodes
+
+ keys = (await db.execute(
+ select(NodeKey).where(NodeKey.user_id == current_user.id)
+ .order_by(NodeKey.linked_at))).scalars().all()
+ nodes = (await db.execute(
+ select(Node).where(Node.user_id == current_user.id))).scalars().all()
+ by_key: dict[str, list[Node]] = {}
+ for n in nodes:
+ by_key.setdefault(n.pk_node, []).append(n)
+
+ out = []
+ for k in keys:
+ seen = [n for n in by_key.get(k.pk_node_ed25519, []) if n.last_seen]
+ latest = max(seen, key=lambda n: n.last_seen) if seen else None
+ out.append({
+ "pk_node_ed25519": k.pk_node_ed25519,
+ "linked_at": k.linked_at.isoformat() if k.linked_at else None,
+ "last_seen": latest.last_seen.isoformat() if latest else None,
+ "observed_ip": latest.observed_ip if latest else None,
+ "connected": any(n.id in _connected_nodes
+ for n in by_key.get(k.pk_node_ed25519, [])),
+ })
+ return {"keys": out, "limit": MAX_NODES_PER_ACCOUNT}
+
+
+@router.put("/me/node_key")
+async def register_node_key(
+ body: NodeKeyRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Link a node daemon's Ed25519 public key to your account, alongside any already linked.
+
+ One key per machine you run a node on; linking one never unlinks another.
+ Linking a key that is already linked changes nothing and answers the same.
+ Refused with 409 past the per-account limit (`limit` in `GET /me/node_keys`).
+ """
+ from meshbay_hub.api.nodes import MAX_NODES_PER_ACCOUNT
+
+ pk = _node_key(body.pk_node_ed25519)
+ existing = await db.execute(
+ select(NodeKey.id).where(NodeKey.user_id == current_user.id,
+ NodeKey.pk_node_ed25519 == pk))
+ if existing.first() is None:
+ held = (await db.execute(
+ select(func.count()).select_from(NodeKey)
+ .where(NodeKey.user_id == current_user.id))).scalar() or 0
+ if held >= MAX_NODES_PER_ACCOUNT:
+ raise HTTPException(
+ status_code=409,
+ detail=f"This account already has {held} linked nodes, which is the "
+ f"limit of {MAX_NODES_PER_ACCOUNT}. Unlink one you no longer run.")
+ db.add(NodeKey(user_id=current_user.id, pk_node_ed25519=pk))
+ try:
+ await db.commit()
+ except IntegrityError:
+ await db.rollback() # the same key, linked by a concurrent request
+ return {"status": "stored", "pk_node_ed25519": pk}
@router.delete("/me/node_key")
async def unlink_node_key(
+ body: NodeKeyRequest | None = None,
+ payload: dict = Depends(_decode_token),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- """Remove the linked node key from the operator's account."""
- current_user.pk_node_ed25519 = None
+ """Unlink one node key from your account. That node is cut off at once; the others are not.
+
+ Its socket to the hub is closed and the tokens it holds stop working, so it
+ serves no group until its key is linked again. From a browser session, name
+ the key. A node's own token may unlink that node and nothing else — the key
+ its token was issued to, named or not — so a node can let go of itself
+ (`meshbay-node setup --reset`, the Node page) but never of a sibling.
+ """
+ from meshbay_hub.api.revocation import disconnect_node
+
+ if payload.get("scope") == "node":
+ own = payload.get("nk")
+ if not own:
+ raise HTTPException(status_code=403,
+ detail="This node token predates per-node keys; sign in again")
+ pk = _node_key(body.pk_node_ed25519) if body else own
+ if pk != own:
+ raise HTTPException(status_code=403,
+ detail="A node may unlink its own key and no other")
+ else:
+ if body is None:
+ raise HTTPException(status_code=422, detail="Name the node key to unlink")
+ pk = _node_key(body.pk_node_ed25519)
+
+ result = await db.execute(
+ delete(NodeKey).where(NodeKey.user_id == current_user.id,
+ NodeKey.pk_node_ed25519 == pk))
+ if not result.rowcount:
+ raise HTTPException(status_code=404, detail="That node key is not linked to this account")
+ node_ids = (await db.execute(
+ select(Node.id).where(Node.user_id == current_user.id,
+ Node.pk_node == pk))).scalars().all()
await db.commit()
- return {"status": "unlinked"}
+ for node_id in node_ids:
+ await disconnect_node(node_id, "Node key unlinked from this account")
+ return {"status": "unlinked", "pk_node_ed25519": pk}
# Key rotation used to live here (`PUT /me/keys`). Identity keys are per node
@@ -1585,6 +1687,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(GroupHost).where(
GroupHost.node_id.in_(select(Node.id).where(Node.user_id == user.id))))
await db.execute(delete(Node).where(Node.user_id == user.id))
+ await db.execute(delete(NodeKey).where(NodeKey.user_id == user.id))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id))
await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id))
@@ -1605,7 +1708,6 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
user.email_hash = None
user.pw_hash = b""
user.pw_salt = b""
- user.pk_node_ed25519 = None
user.bundle_pepper = None
user.status = "deleted"
user.role = "user"
@@ -1647,21 +1749,29 @@ async def get_user_pubkeys(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- """Resolve a username to its account id, and its node's linking key. Not a key directory."""
+ """Resolve a username to its account id. Not a key directory."""
result = await db.execute(select(User).where(User.username == username))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
# Account lookup, not a key directory. `user_id` is how a username is resolved
- # for an invitation, and `pk_node_ed25519` is a node's own linking key. The
- # user identity keys this used to return were H3: whoever asked wrapped the
- # group key for whatever came back.
+ # for an invitation. The user identity keys this used to return were H3:
+ # whoever asked wrapped the group key for whatever came back. Node keys are
+ # not here for anyone else — an account lists its own with `GET
+ # /me/node_keys`, and nobody else learns which machines it runs.
resp = {
"user_id": target.id,
"username": target.username,
}
- if target.pk_node_ed25519:
- resp["pk_node_ed25519"] = target.pk_node_ed25519
+ # Asking about yourself: the latest key you linked, for the desktop
+ # applications already installed, whose interface reads it here to show
+ # the Node and Create group entries.
+ if target.id == current_user.id:
+ latest = (await db.execute(
+ select(NodeKey.pk_node_ed25519).where(NodeKey.user_id == target.id)
+ .order_by(NodeKey.linked_at.desc()).limit(1))).scalar_one_or_none()
+ if latest:
+ resp["pk_node_ed25519"] = latest
return resp
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 41fb159..8cdfee0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -208,6 +208,7 @@ def issue_access_token(
ttl: int = 3600,
groups: list[str] | None = None,
scope: str = "user",
+ node_key: str = "",
) -> str:
"""
Issue a signed JWT access token.
@@ -219,6 +220,8 @@ def issue_access_token(
Includes jti (UUID4) — required to prevent replay and enable revocation.
Includes groups — list of group_ids the user is a member of (node-side authz).
scope: "user" (browser, full access) or "node" (daemon, restricted).
+ node_key: for a node token, the linked key that signed in (`nk`). The hub
+ refuses the token as soon as that key is unlinked (`api/deps.py`).
"""
if _hub_sk_pem is None:
raise RuntimeError("Hub keypair not loaded")
@@ -237,6 +240,8 @@ def issue_access_token(
# the hub (see meshbay_common.tokens).
"aud": HUB_API_AUD,
}
+ if node_key:
+ payload["nk"] = node_key
return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f8a9b0c1d2e3_node_keys.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f8a9b0c1d2e3_node_keys.py
new file mode 100644
index 0000000..b02167b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f8a9b0c1d2e3_node_keys.py
@@ -0,0 +1,63 @@
+"""several node keys per account: one per machine the person runs a node on
+
+Revision ID: f8a9b0c1d2e3
+Revises: e7f8a9b0c1d2
+"""
+
+import uuid
+from collections.abc import Sequence
+from datetime import UTC, datetime
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "f8a9b0c1d2e3"
+down_revision: str | Sequence[str] | None = "e7f8a9b0c1d2"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ node_keys = op.create_table(
+ "node_keys",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("pk_node_ed25519", sa.String(64), nullable=False),
+ sa.Column("linked_at", sa.DateTime(timezone=True)),
+ )
+ op.create_index("uq_node_keys_user_key", "node_keys",
+ ["user_id", "pk_node_ed25519"], unique=True)
+
+ # The key each account had linked keeps working: it becomes that account's
+ # first row, so no node has to be linked again after the upgrade.
+ conn = op.get_bind()
+ linked = conn.execute(sa.text(
+ "SELECT id, pk_node_ed25519 FROM users WHERE pk_node_ed25519 IS NOT NULL "
+ "AND pk_node_ed25519 <> ''")).fetchall()
+ now = datetime.now(UTC)
+ if linked:
+ op.bulk_insert(node_keys, [
+ {"id": str(uuid.uuid4()), "user_id": uid, "pk_node_ed25519": pk,
+ "linked_at": now}
+ for uid, pk in linked])
+
+ with op.batch_alter_table("users") as batch:
+ batch.drop_column("pk_node_ed25519")
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("users") as batch:
+ batch.add_column(sa.Column("pk_node_ed25519", sa.String(64), nullable=True))
+
+ # One column holds one key: the most recently linked one is kept, which is
+ # the node the account would have had linked last before this revision.
+ conn = op.get_bind()
+ rows = conn.execute(sa.text(
+ "SELECT user_id, pk_node_ed25519 FROM node_keys ORDER BY linked_at")).fetchall()
+ latest = {uid: pk for uid, pk in rows}
+ for uid, pk in latest.items():
+ conn.execute(sa.text("UPDATE users SET pk_node_ed25519 = :pk WHERE id = :uid"),
+ {"pk": pk, "uid": uid})
+
+ op.drop_index("uq_node_keys_user_key", table_name="node_keys")
+ op.drop_table("node_keys")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 7291485..e37a479 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -53,7 +53,6 @@ class User(Base):
# the node does the wrapping, nothing reads a key from this directory. Keys
# are generated per node and pinned there (meshbay_node/roster.py).
email_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) # HMAC blind index
- pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True)
hub_id: Mapped[str] = mapped_column(String(128), nullable=False)
# user|moderator|admin
role: Mapped[str] = mapped_column(String(16), default="user")
@@ -104,6 +103,28 @@ class Node(Base):
__table_args__ = (Index("ix_nodes_user_id", "user_id"),)
+class NodeKey(Base):
+ """A node key this account has linked: the key `/v1/nodes/auth` accepts for it.
+
+ One row per machine the person runs a node on. Linking is done from a
+ browser session and nowhere else; a node's own token can neither add nor
+ remove one (`require_user_scope`). Removing the row cuts that node off at
+ once — its socket is closed and the tokens it holds name this key (`nk`),
+ so they stop working — and leaves the account's other nodes alone.
+ """
+
+ __tablename__ = "node_keys"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
+ pk_node_ed25519: Mapped[str] = mapped_column(String(64), nullable=False)
+ linked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+
+ __table_args__ = (
+ Index("uq_node_keys_user_key", "user_id", "pk_node_ed25519", unique=True),
+ )
+
+
# ── Groups ────────────────────────────────────────────────────────────────────
class Group(Base):
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index ea4cee6..195660c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -17,7 +17,7 @@ import {
_storeBundleKey, _loadBundleKey, _clearKeyDB,
loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch,
refreshAccessToken, logoutOnHub,
- mirrorBrowserAccess,
+ mirrorBrowserAccess, linkedNodeKeys,
} from './hub-client.js';
import { startIdleWatch, markActive } from './idle.js';
import { GroupPage } from './group-page.js';
@@ -1089,8 +1089,8 @@ function App() {
if (!user || !platform.capabilities.nodeAdmin) return;
// A late answer, or a retry, must not land on a session that has changed.
const current = () => nodeKeyAskedForRef.current === user;
- const ask = (attempt) => hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
- .then(data => { if (current()) setHasNodeKey(Boolean(data.pk_node_ed25519)); })
+ const ask = (attempt) => linkedNodeKeys(user.token)
+ .then(data => { if (current()) setHasNodeKey(Boolean(data.keys && data.keys.length)); })
.catch(() => {
if (attempt < 3 && current()) setTimeout(() => ask(attempt + 1), 2000 * attempt);
});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index ec4a5b6..6a25d81 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -142,9 +142,10 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl
const linkNodeKey = useCallback(async (pk) => {
if (!pk) return;
- // `PUT /me/node_key` is idempotent — linking the same key again returns 200,
- // so there is no "already linked" case to swallow here. A failure means the
- // hub did not record this node's key (a rejected session, a malformed key),
+ // `PUT /me/node_key` adds this machine's key beside the account's others and
+ // is idempotent — linking the same key again returns 200, so there is no
+ // "already linked" case to swallow here. A failure means the hub did not
+ // record this node's key (a rejected session, a malformed key, the limit),
// and the node then fails to authenticate and never comes up. It must
// surface — `detectNode`'s catch shows it — rather than let the wizard
// proceed against a node that looks linked but is not.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index 8ad091f..84cbaf6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -402,8 +402,15 @@ async function mirrorBrowserAccess(token, userId, known) {
}
}
+// The node keys linked to this account, one per machine it runs a node on:
+// `{ keys: [{ pk_node_ed25519, linked_at, last_seen, observed_ip, connected }],
+// limit }`. The account's own only; nobody else can read them.
+async function linkedNodeKeys(token) {
+ return hubFetch('/v1/users/me/node_keys', { token });
+}
+
export {
- HUB, navigate, session, mirrorBrowserAccess,
+ HUB, navigate, session, mirrorBrowserAccess, linkedNodeKeys,
openDB, IDB_PLAYLISTS,
purgeGroupIndexCache,
_storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 07a58f1..a7aca2c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -538,19 +538,22 @@ export default {
'settings.role': 'Rolle',
'settings.groups': 'Gruppenbenachrichtigungen',
'settings.notifications': 'Benachrichtigungen',
- 'settings.node_key': 'Node verknüpfen',
- 'settings.node_key_desc': 'Wenn Sie einen MeshBay-Node betreiben, fügen Sie hier '
- + 'dessen öffentlichen Ed25519-Schlüssel ein, um ihn mit Ihrem Konto zu '
- + 'verknüpfen. Führen Sie `meshbay-node status` auf dem Rechner des Nodes aus, um '
- + 'den Schlüssel zu lesen, oder kopieren Sie ihn im Desktop-Client unter '
- + 'Node → Overview.',
+ 'settings.node_key': "Verknüpfte Nodes",
+ 'settings.node_key_desc': "Die Nodes, die Sie betreiben, einer pro Rechner. Jeder meldet sich mit seinem eigenen Schlüssel beim Hub an, und die Desktop-App verknüpft den Node ihres Rechners für Sie. Um einen von Hand zu verknüpfen, lesen Sie seinen Schlüssel mit `meshbay-node status` auf diesem Rechner aus oder kopieren Sie ihn in der Desktop-App unter Node → Overview, und fügen Sie ihn hier ein.",
'settings.node_key_placeholder': 'Öffentlichen Ed25519-Schlüssel des Nodes einfügen (base64)',
- 'settings.node_key_submit': 'Node verknüpfen',
- 'settings.node_key_success': 'Node erfolgreich verknüpft',
+ 'settings.node_key_submit': "Node verknüpfen",
+ 'settings.node_key_success': "Node verknüpft",
'settings.node_key_unlink': "Trennen",
- 'settings.node_key_unlink_confirm': "Den Node von Ihrem Konto trennen? Er stellt Ihre Gruppen nicht mehr bereit, bis wieder ein Node verknüpft ist.",
+ 'settings.node_key_unlink_confirm': "Den Node {key} trennen? Er stellt Ihre Gruppen nicht mehr bereit, bis sein Schlüssel wieder verknüpft ist. Ihre anderen Nodes sind nicht betroffen.",
'settings.node_key_unlinked': "Node getrennt",
- 'settings.node_key_current': 'Schlüssel des verknüpften Nodes',
+ 'settings.node_keys_none': "Mit Ihrem Konto ist noch kein Node verknüpft.",
+ 'settings.node_keys_this_machine': "Dieser Rechner",
+ 'settings.node_keys_connected': "Jetzt verbunden",
+ 'settings.node_keys_last_seen': "Zuletzt gesehen {when}",
+ 'settings.node_keys_never': "Hat sich noch nicht angemeldet",
+ 'settings.node_keys_linked_on': "verknüpft am {when}",
+ 'settings.node_keys_count': "Verknüpft: {n} von {limit}",
+ 'settings.node_keys_at_limit': "{limit} Nodes verknüpft, das Maximum für ein Konto. Trennen Sie einen, den Sie nicht mehr betreiben, um einen weiteren zu verknüpfen.",
// Sidebar
'sidebar.create_group': 'Gruppe erstellen',
@@ -960,9 +963,8 @@ export default {
'node.pair_button': 'Diesen Browser koppeln',
'node.pair_success': 'Erfolgreich gekoppelt.',
'node.pair_pending': 'Kopplungscode bereit: Er wird verwendet, wenn Sie das nächste Mal eine Gruppe dieses Nodes öffnen.',
- 'node.link_other_node': "Ihr Konto ist mit dem Node eines anderen Rechners verknüpft, daher kann dieser Node Ihre Gruppen nicht bereitstellen.",
- 'node.link_here': "Stattdessen diesen Node verknüpfen",
- 'node.link_here_confirm': "Diesen Node mit Ihrem Konto verknüpfen? Der derzeit verknüpfte Node auf einem anderen Rechner stellt Ihre Gruppen nicht mehr bereit, bis er erneut verknüpft wird.",
+ 'node.link_here': "Diesen Node verknüpfen",
+ 'node.link_not_linked': "Der Schlüssel dieses Nodes ist nicht mit Ihrem Konto verknüpft, daher kann er Ihre Gruppen nicht bereitstellen. Ihn zu verknüpfen lässt Ihre anderen Nodes unverändert.",
'node.link_other_account': "Dieser Node ist für {account} eingerichtet, nicht für Sie, daher kann er Ihre Gruppen nicht bereitstellen.",
'node.link_take_over': "Diesen Node für mein Konto verwenden",
'node.link_linking': "Verknüpfen…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 3a56f85..fd56d68 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -530,15 +530,22 @@ export default {
'settings.role': 'Role',
'settings.groups': 'Group notifications',
'settings.notifications': 'Notifications',
- 'settings.node_key': 'Link Node',
- 'settings.node_key_desc': 'If you operate a MeshBay node, paste its Ed25519 public key here to link it to your account. Run `meshbay-node status` on the machine running the node to read the key, or copy it from Node \u2192 Overview in the desktop client.',
+ 'settings.node_key': "Linked nodes",
+ 'settings.node_key_desc': "The nodes you run, one per machine. Each signs in to the hub with its own key, and the desktop app links the one on its machine for you. To link one by hand, read its key with `meshbay-node status` on that machine, or copy it from Node → Overview in the desktop app, and paste it here.",
'settings.node_key_placeholder': 'Paste node Ed25519 public key (base64)',
- 'settings.node_key_submit': 'Link Node',
- 'settings.node_key_success': 'Node linked successfully',
+ 'settings.node_key_submit': "Link node",
+ 'settings.node_key_success': "Node linked",
'settings.node_key_unlink': "Unlink",
- 'settings.node_key_unlink_confirm': "Unlink the node from your account? It stops serving your groups until a node is linked again.",
+ 'settings.node_key_unlink_confirm': "Unlink the node {key}? It stops serving your groups until its key is linked again. Your other nodes are not affected.",
'settings.node_key_unlinked': "Node unlinked",
- 'settings.node_key_current': 'Linked node key',
+ 'settings.node_keys_none': "No node is linked to your account yet.",
+ 'settings.node_keys_this_machine': "This machine",
+ 'settings.node_keys_connected': "Connected now",
+ 'settings.node_keys_last_seen': "Last seen {when}",
+ 'settings.node_keys_never': "Has not signed in yet",
+ 'settings.node_keys_linked_on': "linked {when}",
+ 'settings.node_keys_count': "Linked: {n} of {limit}",
+ 'settings.node_keys_at_limit': "{limit} nodes linked, the most an account can have. Unlink one you no longer run to link another.",
// Sidebar
'sidebar.create_group': 'Create group',
@@ -1110,9 +1117,8 @@ export default {
'node.pair_button': 'Pair this browser',
'node.pair_success': 'Paired successfully.',
'node.pair_pending': "Pairing code ready: it is used the next time you open one of this node's groups.",
- 'node.link_other_node': "Your account is linked to the node of another machine, so this node cannot serve your groups.",
- 'node.link_here': "Link this node instead",
- 'node.link_here_confirm': "Link this node to your account? The node linked now, on another machine, stops serving your groups until it is linked again.",
+ 'node.link_here': "Link this node",
+ 'node.link_not_linked': "This node's key is not linked to your account, so it cannot serve your groups. Linking it leaves your other nodes as they are.",
'node.link_other_account': "This node is set up for {account}, not for you, so it cannot serve your groups.",
'node.link_take_over': "Use this node for my account",
'node.link_linking': "Linking…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 5982bdf..908dca4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -534,18 +534,22 @@ export default {
'settings.role': 'Rol',
'settings.groups': 'Notificaciones de grupo',
'settings.notifications': 'Notificaciones',
- 'settings.node_key': 'Vincular un node',
- 'settings.node_key_desc': 'Si administra un node MeshBay, pegue aquí su clave '
- + 'pública Ed25519 para vincularlo a su cuenta. Ejecute `meshbay-node status` en '
- + 'la máquina que aloja el node para leer la clave, o cópiela desde '
- + 'Node → Overview en el cliente de escritorio.',
+ 'settings.node_key': "Nodos vinculados",
+ 'settings.node_key_desc': "Los nodos que ejecutas, uno por máquina. Cada uno se conecta al hub con su propia clave, y la aplicación de escritorio vincula por ti el de su máquina. Para vincular uno a mano, lee su clave con `meshbay-node status` en esa máquina, o cópiala desde Node → Overview en la aplicación de escritorio, y pégala aquí.",
'settings.node_key_placeholder': 'Pegue la clave pública Ed25519 del node (base64)',
- 'settings.node_key_submit': 'Vincular el node',
- 'settings.node_key_success': 'Node vinculado correctamente',
+ 'settings.node_key_submit': "Vincular nodo",
+ 'settings.node_key_success': "Nodo vinculado",
'settings.node_key_unlink': "Desvincular",
- 'settings.node_key_unlink_confirm': "¿Desvincular el nodo de tu cuenta? Dejará de servir tus grupos hasta que se vuelva a vincular un nodo.",
+ 'settings.node_key_unlink_confirm': "¿Desvincular el nodo {key}? Dejará de servir tus grupos hasta que su clave se vuelva a vincular. Tus otros nodos no se ven afectados.",
'settings.node_key_unlinked': "Nodo desvinculado",
- 'settings.node_key_current': 'Clave del node vinculado',
+ 'settings.node_keys_none': "Todavía no hay ningún nodo vinculado a tu cuenta.",
+ 'settings.node_keys_this_machine': "Esta máquina",
+ 'settings.node_keys_connected': "Conectado ahora",
+ 'settings.node_keys_last_seen': "Visto por última vez {when}",
+ 'settings.node_keys_never': "Aún no se ha conectado",
+ 'settings.node_keys_linked_on': "vinculado el {when}",
+ 'settings.node_keys_count': "Vinculados: {n} de {limit}",
+ 'settings.node_keys_at_limit': "{limit} nodos vinculados, el máximo para una cuenta. Desvincula uno que ya no uses para vincular otro.",
// Sidebar
'sidebar.create_group': 'Crear un grupo',
@@ -954,9 +958,8 @@ export default {
'node.pair_button': 'Emparejar este navegador',
'node.pair_success': 'Emparejamiento exitoso.',
'node.pair_pending': 'Código de emparejamiento listo: se usará la próxima vez que abra uno de los grupos de este Node.',
- 'node.link_other_node': "Tu cuenta está vinculada al nodo de otra máquina, así que este nodo no puede servir tus grupos.",
- 'node.link_here': "Vincular este nodo en su lugar",
- 'node.link_here_confirm': "¿Vincular este nodo a tu cuenta? El nodo vinculado ahora, en otra máquina, dejará de servir tus grupos hasta que se vuelva a vincular.",
+ 'node.link_here': "Vincular este nodo",
+ 'node.link_not_linked': "La clave de este nodo no está vinculada a tu cuenta, así que no puede servir tus grupos. Vincularlo no cambia nada en tus otros nodos.",
'node.link_other_account': "Este nodo está configurado para {account}, no para ti, así que no puede servir tus grupos.",
'node.link_take_over': "Usar este nodo para mi cuenta",
'node.link_linking': "Vinculando…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c5fafc0..4b4e036 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -537,18 +537,22 @@ export default {
'settings.role': 'Rôle',
'settings.groups': 'Notifications de groupe',
'settings.notifications': 'Notifications',
- 'settings.node_key': 'Associer un node',
- 'settings.node_key_desc': 'Si vous exploitez un node MeshBay, collez ici sa clé '
- + 'publique Ed25519 pour l’associer à votre compte. Lancez `meshbay-node status` '
- + 'sur la machine qui héberge le node pour lire cette clé, ou copiez-la depuis '
- + 'Node → Overview dans le client de bureau.',
+ 'settings.node_key': "Nodes associés",
+ 'settings.node_key_desc': "Les nodes que vous faites tourner, un par machine. Chacun se connecte au hub avec sa propre clé, et l’application de bureau associe pour vous celui de sa machine. Pour en associer un à la main, lisez sa clé avec `meshbay-node status` sur cette machine, ou copiez-la depuis Node → Overview dans l’application de bureau, puis collez-la ici.",
'settings.node_key_placeholder': 'Collez la clé publique Ed25519 du node (base64)',
- 'settings.node_key_submit': 'Associer le node',
- 'settings.node_key_success': 'Node associé avec succès',
+ 'settings.node_key_submit': "Associer le node",
+ 'settings.node_key_success': "Node associé",
'settings.node_key_unlink': "Délier",
- 'settings.node_key_unlink_confirm': "Délier le node de votre compte ? Il cessera de servir vos groupes jusqu'à ce qu'un node soit lié de nouveau.",
+ 'settings.node_key_unlink_confirm': "Délier le node {key} ? Il cessera de servir vos groupes jusqu’à ce que sa clé soit associée de nouveau. Vos autres nodes ne sont pas touchés.",
'settings.node_key_unlinked': "Node délié",
- 'settings.node_key_current': 'Clé du node associé',
+ 'settings.node_keys_none': "Aucun node n’est encore associé à votre compte.",
+ 'settings.node_keys_this_machine': "Cette machine",
+ 'settings.node_keys_connected': "Connecté",
+ 'settings.node_keys_last_seen': "Vu pour la dernière fois le {when}",
+ 'settings.node_keys_never': "Ne s’est pas encore connecté",
+ 'settings.node_keys_linked_on': "associé le {when}",
+ 'settings.node_keys_count': "Associés : {n} sur {limit}",
+ 'settings.node_keys_at_limit': "{limit} nodes associés, le maximum pour un compte. Déliez-en un que vous n’utilisez plus pour en associer un autre.",
// Sidebar
'sidebar.create_group': 'Créer un groupe',
@@ -957,9 +961,8 @@ export default {
'node.pair_button': 'Appairer ce navigateur',
'node.pair_success': 'Appairage réussi.',
'node.pair_pending': "Code d'appairage prêt : il sera utilisé à la prochaine ouverture d'un des groupes de ce node.",
- 'node.link_other_node': "Votre compte est lié au node d'une autre machine : ce node-ci ne peut pas servir vos groupes.",
- 'node.link_here': "Lier ce node à la place",
- 'node.link_here_confirm': "Lier ce node à votre compte ? Le node lié actuellement, sur une autre machine, cessera de servir vos groupes jusqu'à ce qu'il soit lié de nouveau.",
+ 'node.link_here': "Associer ce node",
+ 'node.link_not_linked': "La clé de ce node n’est pas associée à votre compte : il ne peut pas servir vos groupes. L’associer ne change rien à vos autres nodes.",
'node.link_other_account': "Ce node est configuré pour {account}, pas pour vous : il ne peut pas servir vos groupes.",
'node.link_take_over': "Utiliser ce node pour mon compte",
'node.link_linking': "Liaison…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index a6b548a..27692b8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -537,18 +537,22 @@ export default {
'settings.role': 'Ruolo',
'settings.groups': 'Notifiche dei gruppi',
'settings.notifications': 'Notifiche',
- 'settings.node_key': 'Collega un node',
- 'settings.node_key_desc': 'Se gestisce un node MeshBay, incolli qui la sua chiave '
- + 'pubblica Ed25519 per collegarlo al suo account. Esegua `meshbay-node status` '
- + 'sulla macchina che ospita il node per leggere la chiave, oppure la copi da '
- + 'Node → Overview nel client desktop.',
+ 'settings.node_key': "Node collegati",
+ 'settings.node_key_desc': "I node che gestisci, uno per macchina. Ognuno si collega all’hub con la propria chiave, e l’app desktop collega per te quello della sua macchina. Per collegarne uno a mano, leggi la sua chiave con `meshbay-node status` su quella macchina, oppure copiala da Node → Overview nell’app desktop, e incollala qui.",
'settings.node_key_placeholder': 'Incolli la chiave pubblica Ed25519 del node (base64)',
- 'settings.node_key_submit': 'Collega il node',
- 'settings.node_key_success': 'Node collegato correttamente',
+ 'settings.node_key_submit': "Collega il node",
+ 'settings.node_key_success': "Node collegato",
'settings.node_key_unlink': "Scollega",
- 'settings.node_key_unlink_confirm': "Scollegare il node dal tuo account? Smetterà di servire i tuoi gruppi finché non verrà collegato di nuovo un node.",
+ 'settings.node_key_unlink_confirm': "Scollegare il node {key}? Smetterà di servire i tuoi gruppi finché la sua chiave non verrà collegata di nuovo. Gli altri tuoi node non cambiano.",
'settings.node_key_unlinked': "Node scollegato",
- 'settings.node_key_current': 'Chiave del node collegato',
+ 'settings.node_keys_none': "Nessun node è ancora collegato al tuo account.",
+ 'settings.node_keys_this_machine': "Questa macchina",
+ 'settings.node_keys_connected': "Connesso ora",
+ 'settings.node_keys_last_seen': "Visto l’ultima volta {when}",
+ 'settings.node_keys_never': "Non si è ancora collegato",
+ 'settings.node_keys_linked_on': "collegato il {when}",
+ 'settings.node_keys_count': "Collegati: {n} su {limit}",
+ 'settings.node_keys_at_limit': "{limit} node collegati, il massimo per un account. Scollegane uno che non usi più per collegarne un altro.",
// Sidebar
'sidebar.create_group': 'Crea un gruppo',
@@ -956,9 +960,8 @@ export default {
'node.pair_button': 'Associa questo browser',
'node.pair_success': 'Associazione riuscita.',
'node.pair_pending': 'Codice di associazione pronto: verrà usato la prossima volta che apri uno dei gruppi di questo Node.',
- 'node.link_other_node': "Il tuo account è collegato al node di un'altra macchina, quindi questo node non può servire i tuoi gruppi.",
- 'node.link_here': "Collega invece questo node",
- 'node.link_here_confirm': "Collegare questo node al tuo account? Il node collegato ora, su un'altra macchina, smetterà di servire i tuoi gruppi finché non verrà ricollegato.",
+ 'node.link_here': "Collega questo node",
+ 'node.link_not_linked': "La chiave di questo node non è collegata al tuo account, quindi non può servire i tuoi gruppi. Collegarlo non cambia nulla per gli altri tuoi node.",
'node.link_other_account': "Questo node è configurato per {account}, non per te, quindi non può servire i tuoi gruppi.",
'node.link_take_over': "Usa questo node per il mio account",
'node.link_linking': "Collegamento…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index c6d93cc..1b2c230 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -531,18 +531,22 @@ export default {
'settings.role': '権限',
'settings.groups': 'グループの通知',
'settings.notifications': '通知',
- 'settings.node_key': 'node を連携',
- 'settings.node_key_desc': 'MeshBay の node を運営している場合は、その Ed25519 '
- + '公開鍵をここに貼り付けて、アカウントに連携してください。'
- + 'node を動かしているマシンで `meshbay-node status` を実行するか、'
- + 'デスクトップクライアントの Node → Overview からコピーできます。',
+ 'settings.node_key': "連携中の node",
+ 'settings.node_key_desc': "あなたが動かしている node(マシンごとに 1 台)です。それぞれが自分の鍵で hub にサインインし、デスクトップアプリはそのマシンの node を自動で連携します。手動で連携するには、そのマシンで `meshbay-node status` を実行して鍵を確認するか、デスクトップアプリの Node → Overview からコピーして、ここに貼り付けてください。",
'settings.node_key_placeholder': 'node の Ed25519 公開鍵を貼り付け(base64)',
- 'settings.node_key_submit': 'node を連携',
- 'settings.node_key_success': 'node を連携しました',
+ 'settings.node_key_submit': "node を連携",
+ 'settings.node_key_success': "node を連携しました",
'settings.node_key_unlink': "リンク解除",
- 'settings.node_key_unlink_confirm': "アカウントからノードのリンクを解除しますか?再びノードがリンクされるまで、グループを提供しなくなります。",
+ 'settings.node_key_unlink_confirm': "node {key} の連携を解除しますか?鍵が再び連携されるまで、グループを提供しなくなります。ほかの node には影響しません。",
'settings.node_key_unlinked': "ノードのリンクを解除しました",
- 'settings.node_key_current': '連携済みの node 鍵',
+ 'settings.node_keys_none': "まだアカウントに連携された node はありません。",
+ 'settings.node_keys_this_machine': "このマシン",
+ 'settings.node_keys_connected': "接続中",
+ 'settings.node_keys_last_seen': "最終接続 {when}",
+ 'settings.node_keys_never': "まだサインインしていません",
+ 'settings.node_keys_linked_on': "{when} に連携",
+ 'settings.node_keys_count': "連携数: {n} / {limit}",
+ 'settings.node_keys_at_limit': "{limit} 台の node が連携済みで、1 アカウントの上限です。別の node を連携するには、使っていない node の連携を解除してください。",
// Sidebar
'sidebar.create_group': 'グループを作成',
@@ -944,9 +948,8 @@ export default {
'node.pair_button': 'このブラウザをペアリング',
'node.pair_success': 'ペアリングに成功しました。',
'node.pair_pending': 'ペアリングコードの準備ができました。この Node のグループを次に開いたときに使用されます。',
- 'node.link_other_node': "アカウントは別のマシンのノードにリンクされているため、このノードではグループを提供できません。",
- 'node.link_here': "代わりにこのノードをリンク",
- 'node.link_here_confirm': "このノードをアカウントにリンクしますか?現在リンクされている別のマシンのノードは、再びリンクされるまでグループを提供しなくなります。",
+ 'node.link_here': "この node を連携",
+ 'node.link_not_linked': "この node の鍵はアカウントに連携されていないため、グループを提供できません。連携しても、ほかの node はそのままです。",
'node.link_other_account': "このノードはあなたではなく {account} 用に設定されているため、グループを提供できません。",
'node.link_take_over': "このノードを自分のアカウントで使う",
'node.link_linking': "リンク中…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 481288e..971b046 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -538,18 +538,22 @@ export default {
'settings.role': 'Rol',
'settings.groups': 'Groepsmeldingen',
'settings.notifications': 'Meldingen',
- 'settings.node_key': 'Node koppelen',
- 'settings.node_key_desc': 'Als u een MeshBay-node beheert, plak hier dan de '
- + 'openbare Ed25519-sleutel ervan om hem aan uw account te koppelen. Voer '
- + '`meshbay-node status` uit op de machine waarop de node draait om de sleutel te '
- + 'lezen, of kopieer hem via Node → Overview in de desktopclient.',
+ 'settings.node_key': "Gekoppelde nodes",
+ 'settings.node_key_desc': "De nodes die je draait, één per machine. Elke node meldt zich met zijn eigen sleutel aan bij de hub, en de desktop-app koppelt de node van zijn machine voor je. Om er een met de hand te koppelen, lees je de sleutel uit met `meshbay-node status` op die machine, of kopieer je hem via Node → Overview in de desktop-app, en plak je hem hier.",
'settings.node_key_placeholder': 'Plak de openbare Ed25519-sleutel van de node (base64)',
- 'settings.node_key_submit': 'Node koppelen',
- 'settings.node_key_success': 'Node gekoppeld',
+ 'settings.node_key_submit': "Node koppelen",
+ 'settings.node_key_success': "Node gekoppeld",
'settings.node_key_unlink': "Ontkoppelen",
- 'settings.node_key_unlink_confirm': "De node van je account ontkoppelen? Hij bedient je groepen niet meer tot er weer een node is gekoppeld.",
+ 'settings.node_key_unlink_confirm': "De node {key} ontkoppelen? Hij bedient je groepen niet meer tot zijn sleutel opnieuw is gekoppeld. Je andere nodes blijven zoals ze zijn.",
'settings.node_key_unlinked': "Node ontkoppeld",
- 'settings.node_key_current': 'Sleutel van de gekoppelde node',
+ 'settings.node_keys_none': "Er is nog geen node aan je account gekoppeld.",
+ 'settings.node_keys_this_machine': "Deze machine",
+ 'settings.node_keys_connected': "Nu verbonden",
+ 'settings.node_keys_last_seen': "Laatst gezien {when}",
+ 'settings.node_keys_never': "Heeft zich nog niet aangemeld",
+ 'settings.node_keys_linked_on': "gekoppeld op {when}",
+ 'settings.node_keys_count': "Gekoppeld: {n} van {limit}",
+ 'settings.node_keys_at_limit': "{limit} nodes gekoppeld, het maximum voor een account. Ontkoppel er een die je niet meer draait om een andere te koppelen.",
// Sidebar
'sidebar.create_group': 'Groep aanmaken',
@@ -958,9 +962,8 @@ export default {
'node.pair_button': 'Deze browser koppelen',
'node.pair_success': 'Succesvol gekoppeld.',
'node.pair_pending': 'Koppelingscode klaar: die wordt gebruikt zodra u een van de groepen van deze Node opent.',
- 'node.link_other_node': "Je account is gekoppeld aan de node van een andere computer, dus deze node kan je groepen niet bedienen.",
- 'node.link_here': "Koppel in plaats daarvan deze node",
- 'node.link_here_confirm': "Deze node aan je account koppelen? De nu gekoppelde node op een andere computer bedient je groepen niet meer tot hij opnieuw wordt gekoppeld.",
+ 'node.link_here': "Deze node koppelen",
+ 'node.link_not_linked': "De sleutel van deze node is niet aan je account gekoppeld, dus hij kan je groepen niet bedienen. Koppelen laat je andere nodes zoals ze zijn.",
'node.link_other_account': "Deze node is ingesteld voor {account}, niet voor jou, dus hij kan je groepen niet bedienen.",
'node.link_take_over': "Deze node voor mijn account gebruiken",
'node.link_linking': "Koppelen…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 6e93758..5092e5c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -552,18 +552,22 @@ export default {
'settings.role': 'Rola',
'settings.groups': 'Powiadomienia z grup',
'settings.notifications': 'Powiadomienia',
- 'settings.node_key': 'Powiąż node',
- 'settings.node_key_desc': 'Osoby prowadzące własny node MeshBay mogą wkleić tutaj '
- + 'jego klucz publiczny Ed25519, aby powiązać go ze swoim kontem. Aby odczytać '
- + 'klucz, uruchom `meshbay-node status` na maszynie z node lub skopiuj go z '
- + 'Node → Overview w kliencie desktopowym.',
+ 'settings.node_key': "Powiązane węzły",
+ 'settings.node_key_desc': "Węzły, które prowadzisz, po jednym na maszynę. Każdy loguje się do huba własnym kluczem, a aplikacja desktopowa sama wiąże węzeł swojej maszyny. Aby powiązać węzeł ręcznie, odczytaj jego klucz poleceniem `meshbay-node status` na tej maszynie lub skopiuj go z Node → Overview w aplikacji desktopowej i wklej tutaj.",
'settings.node_key_placeholder': 'Wklej klucz publiczny Ed25519 node (base64)',
- 'settings.node_key_submit': 'Powiąż node',
- 'settings.node_key_success': 'Node został powiązany',
+ 'settings.node_key_submit': "Powiąż węzeł",
+ 'settings.node_key_success': "Węzeł powiązany",
'settings.node_key_unlink': "Odłącz",
- 'settings.node_key_unlink_confirm': "Odłączyć węzeł od twojego konta? Przestanie obsługiwać twoje grupy, dopóki nie zostanie ponownie powiązany węzeł.",
+ 'settings.node_key_unlink_confirm': "Odłączyć węzeł {key}? Przestanie obsługiwać twoje grupy, dopóki jego klucz nie zostanie ponownie powiązany. Twoje pozostałe węzły nie zostaną zmienione.",
'settings.node_key_unlinked': "Węzeł odłączony",
- 'settings.node_key_current': 'Klucz powiązanego node',
+ 'settings.node_keys_none': "Z twoim kontem nie jest jeszcze powiązany żaden węzeł.",
+ 'settings.node_keys_this_machine': "Ta maszyna",
+ 'settings.node_keys_connected': "Połączony",
+ 'settings.node_keys_last_seen': "Ostatnio widziany {when}",
+ 'settings.node_keys_never': "Jeszcze się nie zalogował",
+ 'settings.node_keys_linked_on': "powiązany {when}",
+ 'settings.node_keys_count': "Powiązane: {n} z {limit}",
+ 'settings.node_keys_at_limit': "Powiązano {limit} węzłów, maksimum dla jednego konta. Odłącz węzeł, którego już nie używasz, aby powiązać kolejny.",
// Sidebar
'sidebar.create_group': 'Utwórz grupę',
@@ -978,9 +982,8 @@ export default {
'node.pair_button': 'Sparuj tę przeglądarkę',
'node.pair_success': 'Parowanie zakończone sukcesem.',
'node.pair_pending': 'Kod parowania gotowy: zostanie użyty przy następnym otwarciu jednej z grup tego Node.',
- 'node.link_other_node': "Twoje konto jest powiązane z węzłem innego komputera, więc ten węzeł nie może obsługiwać twoich grup.",
- 'node.link_here': "Powiąż zamiast tego ten węzeł",
- 'node.link_here_confirm': "Powiązać ten węzeł z twoim kontem? Węzeł powiązany teraz, na innym komputerze, przestanie obsługiwać twoje grupy, dopóki nie zostanie ponownie powiązany.",
+ 'node.link_here': "Powiąż ten węzeł",
+ 'node.link_not_linked': "Klucz tego węzła nie jest powiązany z twoim kontem, więc nie może on obsługiwać twoich grup. Powiązanie go nie zmienia twoich pozostałych węzłów.",
'node.link_other_account': "Ten węzeł jest skonfigurowany dla {account}, nie dla ciebie, więc nie może obsługiwać twoich grup.",
'node.link_take_over': "Użyj tego węzła dla mojego konta",
'node.link_linking': "Wiązanie…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 5a6d1a4..075cc31 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -536,18 +536,22 @@ export default {
'settings.role': 'Função',
'settings.groups': 'Notificações de grupo',
'settings.notifications': 'Notificações',
- 'settings.node_key': 'Vincular um node',
- 'settings.node_key_desc': 'Se você opera um node MeshBay, cole aqui a chave pública '
- + 'Ed25519 dele para vinculá-lo à sua conta. Execute `meshbay-node status` na '
- + 'máquina que hospeda o node para ler a chave, ou copie-a em '
- + 'Node → Overview no cliente desktop.',
+ 'settings.node_key': "Nodes vinculados",
+ 'settings.node_key_desc': "Os nodes que você executa, um por máquina. Cada um entra no hub com a própria chave, e o app desktop vincula para você o node da sua máquina. Para vincular um manualmente, leia a chave com `meshbay-node status` nessa máquina, ou copie-a em Node → Overview no app desktop, e cole-a aqui.",
'settings.node_key_placeholder': 'Cole a chave pública Ed25519 do node (base64)',
- 'settings.node_key_submit': 'Vincular o node',
- 'settings.node_key_success': 'Node vinculado com sucesso',
+ 'settings.node_key_submit': "Vincular o node",
+ 'settings.node_key_success': "Node vinculado",
'settings.node_key_unlink': "Desvincular",
- 'settings.node_key_unlink_confirm': "Desvincular o node da sua conta? Ele deixará de servir seus grupos até que um node seja vinculado novamente.",
+ 'settings.node_key_unlink_confirm': "Desvincular o node {key}? Ele deixará de servir seus grupos até que a chave dele seja vinculada novamente. Seus outros nodes não são afetados.",
'settings.node_key_unlinked': "Node desvinculado",
- 'settings.node_key_current': 'Chave do node vinculado',
+ 'settings.node_keys_none': "Nenhum node está vinculado à sua conta ainda.",
+ 'settings.node_keys_this_machine': "Esta máquina",
+ 'settings.node_keys_connected': "Conectado agora",
+ 'settings.node_keys_last_seen': "Visto pela última vez {when}",
+ 'settings.node_keys_never': "Ainda não entrou",
+ 'settings.node_keys_linked_on': "vinculado em {when}",
+ 'settings.node_keys_count': "Vinculados: {n} de {limit}",
+ 'settings.node_keys_at_limit': "{limit} nodes vinculados, o máximo para uma conta. Desvincule um que você não usa mais para vincular outro.",
// Sidebar
'sidebar.create_group': 'Criar um grupo',
@@ -955,9 +959,8 @@ export default {
'node.pair_button': 'Parear este navegador',
'node.pair_success': 'Pareamento realizado com sucesso.',
'node.pair_pending': 'Código de pareamento pronto: ele será usado na próxima vez que você abrir um dos grupos deste Node.',
- 'node.link_other_node': "Sua conta está vinculada ao node de outra máquina, então este node não pode servir seus grupos.",
- 'node.link_here': "Vincular este node no lugar",
- 'node.link_here_confirm': "Vincular este node à sua conta? O node vinculado agora, em outra máquina, deixará de servir seus grupos até ser vinculado novamente.",
+ 'node.link_here': "Vincular este node",
+ 'node.link_not_linked': "A chave deste node não está vinculada à sua conta, então ele não pode servir seus grupos. Vinculá-lo não muda nada nos seus outros nodes.",
'node.link_other_account': "Este node está configurado para {account}, não para você, então não pode servir seus grupos.",
'node.link_take_over': "Usar este node na minha conta",
'node.link_linking': "Vinculando…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index be45b52..0ef354e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -525,18 +525,22 @@ export default {
'settings.role': '角色',
'settings.groups': '群组通知',
'settings.notifications': '通知',
- 'settings.node_key': '关联 node',
- 'settings.node_key_desc': '如果您运营着一个 MeshBay node,请在此粘贴它的 Ed25519 '
- + '公钥,以将其关联到您的账户。在运行 node 的机器上执行 '
- + '`meshbay-node status` 可读取该密钥,或在桌面客户端的 '
- + 'Node → Overview 中复制。',
+ 'settings.node_key': "已关联的节点",
+ 'settings.node_key_desc': "你运行的节点,每台机器一个。每个节点用自己的密钥登录 hub,桌面应用会自动关联本机的节点。如需手动关联,请在那台机器上执行 `meshbay-node status` 读取密钥,或在桌面应用的 Node → Overview 中复制,然后粘贴到这里。",
'settings.node_key_placeholder': '粘贴 node 的 Ed25519 公钥(base64)',
- 'settings.node_key_submit': '关联 node',
- 'settings.node_key_success': 'node 关联成功',
+ 'settings.node_key_submit': "关联节点",
+ 'settings.node_key_success': "节点已关联",
'settings.node_key_unlink': "取消关联",
- 'settings.node_key_unlink_confirm': "要从你的账户取消关联该节点吗?在再次关联节点之前,它将停止为你的群组提供服务。",
+ 'settings.node_key_unlink_confirm': "要取消关联节点 {key} 吗?在其密钥再次关联之前,它将停止为你的群组提供服务。你的其他节点不受影响。",
'settings.node_key_unlinked': "已取消关联节点",
- 'settings.node_key_current': '已关联的 node 密钥',
+ 'settings.node_keys_none': "你的账户还没有关联任何节点。",
+ 'settings.node_keys_this_machine': "本机",
+ 'settings.node_keys_connected': "当前已连接",
+ 'settings.node_keys_last_seen': "最后在线 {when}",
+ 'settings.node_keys_never': "尚未登录",
+ 'settings.node_keys_linked_on': "关联于 {when}",
+ 'settings.node_keys_count': "已关联:{n} / {limit}",
+ 'settings.node_keys_at_limit': "已关联 {limit} 个节点,这是一个账户的上限。请取消关联一个不再运行的节点,再关联新的。",
// Sidebar
'sidebar.create_group': '创建群组',
@@ -932,9 +936,8 @@ export default {
'node.pair_button': '配对此浏览器',
'node.pair_success': '配对成功。',
'node.pair_pending': '配对码已就绪:下次打开此 Node 的任一群组时将自动使用。',
- 'node.link_other_node': "你的账户已关联到另一台机器的节点,因此此节点无法为你的群组提供服务。",
- 'node.link_here': "改为关联此节点",
- 'node.link_here_confirm': "将此节点关联到你的账户?当前关联的另一台机器上的节点将停止为你的群组提供服务,直到再次关联。",
+ 'node.link_here': "关联此节点",
+ 'node.link_not_linked': "此节点的密钥未关联到你的账户,因此无法为你的群组提供服务。关联它不会影响你的其他节点。",
'node.link_other_account': "此节点是为 {account} 而不是为你设置的,因此无法为你的群组提供服务。",
'node.link_take_over': "将此节点用于我的账户",
'node.link_linking': "正在关联…",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index 88167d3..bc22cbd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -6,7 +6,7 @@ import { t } from './i18n.js';
import { ask } from './ask.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
-import { HUB, session, hubFetch } from './hub-client.js';
+import { HUB, session, hubFetch, linkedNodeKeys } from './hub-client.js';
// ── Node management (D5) ────────────────────────────────────────────────────
//
@@ -23,25 +23,27 @@ export function pairedFrom(result) {
}
// Why this machine's node cannot serve the signed-in account, if it cannot:
-// 'other_account' when it is set up for another account or hub, 'other_node'
-// when the account is linked to another node's key. Signing in links this
-// node (main.js ensureNode) but never over either, since that would cut off
-// the user's other machine or someone else's node; both left a node reading
-// "Running" that the hub refused in a loop, with nothing here saying why
-// (found on a real install, 2026-10-10). Worked out from the node and the hub
-// each time the page looks, not kept from sign-in: a link made or removed on
-// the Profile page or on the other machine changes the answer.
-export function nodeLinkProblem(info, linkedKey, username, hub) {
+// 'other_account' when it is set up for another account or hub, 'not_linked'
+// when its key is not among the account's linked keys. An account links one
+// key per machine it runs a node on, so linking this one cuts off no other.
+// Signing in links it (main.js ensureNode) but never over another account's
+// node; both cases left a node reading "Running" that the hub refused in a
+// loop, with nothing here saying why (found on a real install, 2026-10-10).
+// Worked out from the node and the hub each time the page looks, not kept from
+// sign-in: a key linked or unlinked on the Profile page changes the answer.
+// `linkedKeys` is null until the hub has answered, so nothing is claimed before.
+export function nodeLinkProblem(info, linkedKeys, username, hub) {
if (!info || !username) return null;
const sameHub = (a, b) => String(a || '').replace(/\/+$/, '') === String(b || '').replace(/\/+$/, '');
if ((info.username && info.username !== username)
|| (info.hub_url && hub && !sameHub(info.hub_url, hub))) return 'other_account';
- if (linkedKey && info.pk_node_ed25519 && linkedKey !== info.pk_node_ed25519) return 'other_node';
+ if (Array.isArray(linkedKeys) && info.pk_node_ed25519
+ && !linkedKeys.includes(info.pk_node_ed25519)) return 'not_linked';
return null;
}
function NodeLinkBanner({ info, token, username, onLinked }) {
- const [linkedKey, setLinkedKey] = useState(null);
+ const [linkedKeys, setLinkedKeys] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const nodeKey = info && info.pk_node_ed25519;
@@ -49,33 +51,33 @@ function NodeLinkBanner({ info, token, username, onLinked }) {
useEffect(() => {
if (!username || !token) return;
let cancelled = false;
- hubFetch(`/v1/users/${encodeURIComponent(username)}/pubkeys`, { token })
- .then((k) => { if (!cancelled) setLinkedKey((k && k.pk_node_ed25519) || null); })
+ linkedNodeKeys(token)
+ .then((r) => {
+ if (!cancelled) setLinkedKeys(((r && r.keys) || []).map(k => k.pk_node_ed25519));
+ })
.catch(() => {});
return () => { cancelled = true; };
}, [username, token, nodeKey]);
- const problem = nodeLinkProblem(info, linkedKey, username, HUB);
+ const problem = nodeLinkProblem(info, linkedKeys, username, HUB);
if (!problem) return null;
- // Another node's key: this node's replaces it on the account, and a node
- // waiting for its account signs in on its next attempt. Not node:start,
- // which leaves a node that answers "running" alone, and one can (signed in
- // before the account was linked elsewhere). Another account: node:start
- // switches it, after asking, restarts it and links it.
+ // Not linked: this node's key is added beside the account's others, and a
+ // node waiting for its link signs in on its next attempt. Not node:start,
+ // which leaves a node that answers "running" alone. Another account:
+ // node:start switches it, after asking, restarts it and links it.
const takeOver = async () => {
- if (problem === 'other_node' && !await ask(t('node.link_here_confirm'))) return;
setBusy(true);
setErr('');
try {
- if (problem === 'other_node') {
+ if (problem === 'not_linked') {
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token, body: { pk_node_ed25519: nodeKey },
});
} else {
await platform.node.start({ hubUrl: HUB, username, token, takeOver: true });
}
- setLinkedKey(nodeKey);
+ setLinkedKeys([...(linkedKeys || []), nodeKey]);
if (onLinked) onLinked();
} catch (e) {
setErr(platform.bridgeMessage(e));
@@ -86,12 +88,12 @@ function NodeLinkBanner({ info, token, username, onLinked }) {
return html`
<div class="node-pair-banner node-link-banner">
- <p>${problem === 'other_node'
- ? t('node.link_other_node')
+ <p>${problem === 'not_linked'
+ ? t('node.link_not_linked')
: t('node.link_other_account', { account: `${info.username} @ ${info.hub_url}` })}</p>
<button class="btn btn-primary btn-small" disabled=${busy} onClick=${takeOver}>
${busy ? t('node.link_linking')
- : problem === 'other_node' ? t('node.link_here') : t('node.link_take_over')}</button>
+ : problem === 'not_linked' ? t('node.link_here') : t('node.link_take_over')}</button>
${err && html`<p class="error-msg">${err}</p>`}
</div>`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 95c3f3a..fa2065d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -416,7 +416,8 @@ export const node = {
/**
* After sign-in: set up, start and link the node this application ships,
* for the signed-in account ({username, token}). Leaves alone a node set up
- * for another account and an account linked to another node. Resolves to
+ * for another account; links this machine's key beside the account's
+ * others. Resolves to
* `{ state }`; a build or platform with nothing to do answers 'unsupported'.
*/
async ensure(opts) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index b52a8c6..406ffc8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -6,7 +6,7 @@ import { ask } from './ask.js';
import { Icon } from './icon.js';
import * as platform from './platform.js';
import {
- hubFetch, HUB, session, setAuth, mirrorBrowserAccess,
+ hubFetch, HUB, session, setAuth, mirrorBrowserAccess, linkedNodeKeys,
_storeBundleKey, _loadBundleKey, _storeRecoveryKey,
} from './hub-client.js';
@@ -25,12 +25,36 @@ async function lockedMessage(token) {
}
}
+// An Ed25519 key is 44 characters with nothing to tell two apart at a glance;
+// both ends are what a person compares against `meshbay-node status`.
+export function shortNodeKey(pk) {
+ const k = String(pk || '');
+ return k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k;
+}
+
+function nodeKeyMeta(k) {
+ const parts = [k.connected ? t('settings.node_keys_connected')
+ : k.last_seen ? t('settings.node_keys_last_seen',
+ { when: new Date(k.last_seen).toLocaleString() })
+ : t('settings.node_keys_never')];
+ if (k.observed_ip) parts.push(k.observed_ip);
+ if (k.linked_at) {
+ parts.push(t('settings.node_keys_linked_on',
+ { when: new Date(k.linked_at).toLocaleDateString() }));
+ }
+ return parts;
+}
+
export function ProfilePage({ user, onLogout }) {
const [nodeKey, setNodeKey] = useState('');
const [hubInput, setHubInput] = useState('');
const [hubError, setHubError] = useState('');
- const [currentNodeKey, setCurrentNodeKey] = useState(null);
- const [nodeKeyStatus, setNodeKeyStatus] = useState('');
+ // null until the hub answers, so "no node linked" is never said before it has.
+ const [nodeKeys, setNodeKeys] = useState(null);
+ const [nodeKeyLimit, setNodeKeyLimit] = useState(0);
+ const [thisMachineKey, setThisMachineKey] = useState('');
+ const [copiedKey, setCopiedKey] = useState('');
+ const [nodeKeyStatus, setNodeKeyStatus] = useState(null); // { text, ok }
const [nodeKeyLoading, setNodeKeyLoading] = useState(false);
const [email, setEmail] = useState('');
const [emailDraft, setEmailDraft] = useState('');
@@ -289,13 +313,29 @@ export function ProfilePage({ user, onLogout }) {
if (data.email) { setEmail(data.email); setEmailDraft(data.email); }
})
.catch(() => {});
- hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
- .then(data => {
- if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519);
- })
- .catch(() => {});
}, [user.username, user.token]);
+ const loadNodeKeys = useCallback(async () => {
+ try {
+ const data = await linkedNodeKeys(user.token);
+ setNodeKeys(data.keys || []);
+ setNodeKeyLimit(data.limit || 0);
+ } catch (e) {
+ setNodeKeyStatus({ text: e.message, ok: false });
+ }
+ }, [user.token]);
+
+ useEffect(() => { loadNodeKeys(); }, [loadNodeKeys]);
+
+ // In the desktop app, which of the linked keys is this machine's own node:
+ // the keys alone do not say which machine is which.
+ useEffect(() => {
+ if (!platform.node.service || !platform.node.service.available) return;
+ platform.node.service.status()
+ .then(r => setThisMachineKey((r && r.pk_node_ed25519) || ''))
+ .catch(() => {});
+ }, []);
+
const saveEmail = useCallback(async () => {
const val = emailDraft.trim();
if (!val || val === email) { setEmailEditing(false); return; }
@@ -354,41 +394,52 @@ export function ProfilePage({ user, onLogout }) {
const key = nodeKey.trim();
if (!key) return;
setNodeKeyLoading(true);
- setNodeKeyStatus('');
+ setNodeKeyStatus(null);
try {
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token: user.token,
body: { pk_node_ed25519: key },
});
- setCurrentNodeKey(key);
setNodeKey('');
- setNodeKeyStatus(t('settings.node_key_success'));
+ setNodeKeyStatus({ text: t('settings.node_key_success'), ok: true });
+ await loadNodeKeys();
} catch (e) {
- setNodeKeyStatus(e.message);
+ setNodeKeyStatus({ text: e.message, ok: false });
} finally {
setNodeKeyLoading(false);
}
- }, [nodeKey, user.token]);
+ }, [nodeKey, user.token, loadNodeKeys]);
- // Unlinking was only on the linked machine's own Node page, so an account
- // whose node was on a machine since wiped or lost had no way out here; and a
- // new machine's node will not take over a key that is still linked
- // (main.js ensureNode). The hub accepts it from any session of the account.
- const unlinkNodeKey = useCallback(async () => {
- if (!await ask(t('settings.node_key_unlink_confirm'))) return;
+ // Any session of the account can let go of any of its nodes, so a machine
+ // since wiped or lost is not stuck on the account. Only the one named stops
+ // serving; the hub closes its connection at once.
+ const unlinkNodeKey = useCallback(async (pk) => {
+ if (!await ask(t('settings.node_key_unlink_confirm', { key: shortNodeKey(pk) }))) return;
setNodeKeyLoading(true);
- setNodeKeyStatus('');
+ setNodeKeyStatus(null);
try {
- await hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token });
- setCurrentNodeKey(null);
- setNodeKeyStatus(t('settings.node_key_unlinked'));
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'DELETE', token: user.token, body: { pk_node_ed25519: pk },
+ });
+ setNodeKeys(keys => (keys || []).filter(k => k.pk_node_ed25519 !== pk));
+ setNodeKeyStatus({ text: t('settings.node_key_unlinked'), ok: true });
} catch (e) {
- setNodeKeyStatus(e.message);
+ setNodeKeyStatus({ text: e.message, ok: false });
} finally {
setNodeKeyLoading(false);
}
}, [user.token]);
+ const copyNodeKey = useCallback(async (pk) => {
+ try {
+ await navigator.clipboard.writeText(pk);
+ setCopiedKey(pk);
+ setTimeout(() => setCopiedKey(c => (c === pk ? '' : c)), 2000);
+ } catch { /* clipboard blocked -- the full key is in the tooltip */ }
+ }, []);
+
+ const atLimit = nodeKeys !== null && nodeKeyLimit > 0 && nodeKeys.length >= nodeKeyLimit;
+
return html`
<div>
<h2>${t('profile.title')}</h2>
@@ -449,31 +500,53 @@ export function ProfilePage({ user, onLogout }) {
<div class="settings-section">
<h3 class="settings-heading">${t('settings.node_key')}</h3>
- <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">${t('settings.node_key_desc')}</p>
- ${currentNodeKey && html`
- <div class="settings-row" style="margin-top:8px">
- <span class="settings-label">${t('settings.node_key_current')}</span>
- <code class="settings-value" style="font-size:0.8em;word-break:break-all">${currentNodeKey}</code>
- <button class="admin-btn" onClick=${unlinkNodeKey} disabled=${nodeKeyLoading}>
- ${t('settings.node_key_unlink')}
- </button>
- </div>
- `}
- <div style="display:flex;gap:8px;margin-top:10px;align-items:center">
- <input type="text" class="admin-search" style="flex:1;font-family:monospace;font-size:0.85em"
+ <p class="settings-hint">${t('settings.node_key_desc')}</p>
+ ${nodeKeys !== null && nodeKeys.length === 0 && html`
+ <p class="node-keys-empty">${t('settings.node_keys_none')}</p>`}
+ ${nodeKeys !== null && nodeKeys.length > 0 && html`
+ <ul class="node-keys">
+ ${nodeKeys.map(k => html`
+ <li class="node-keys-item" key=${k.pk_node_ed25519}>
+ <span class=${`node-keys-dot${k.connected ? ' on' : ''}`} aria-hidden="true"></span>
+ <div class="node-keys-main">
+ <div class="node-keys-line">
+ <code class="node-keys-key" title=${k.pk_node_ed25519}>
+ ${shortNodeKey(k.pk_node_ed25519)}</code>
+ ${k.pk_node_ed25519 === thisMachineKey && html`
+ <span class="node-keys-badge">${t('settings.node_keys_this_machine')}</span>`}
+ </div>
+ <div class="node-keys-meta">${nodeKeyMeta(k).join(' · ')}</div>
+ </div>
+ <div class="node-keys-actions">
+ <button class="btn-secondary btn-small" onClick=${() => copyNodeKey(k.pk_node_ed25519)}>
+ ${copiedKey === k.pk_node_ed25519 ? t('node.overview_node_key_copied')
+ : t('node.overview_node_key_copy')}</button>
+ <button class="admin-btn danger" disabled=${nodeKeyLoading}
+ onClick=${() => unlinkNodeKey(k.pk_node_ed25519)}>
+ ${t('settings.node_key_unlink')}</button>
+ </div>
+ </li>`)}
+ </ul>`}
+ <div class="node-keys-add">
+ <input type="text" class="admin-search node-keys-input"
placeholder=${t('settings.node_key_placeholder')}
value=${nodeKey} onInput=${e => setNodeKey(e.target.value)}
- onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} />
+ onKeyDown=${e => e.key === 'Enter' && !atLimit && submitNodeKey()}
+ disabled=${atLimit} aria-label=${t('settings.node_key_placeholder')} />
<button class="admin-btn" onClick=${submitNodeKey}
- disabled=${nodeKeyLoading || !nodeKey.trim()}>
+ disabled=${nodeKeyLoading || atLimit || !nodeKey.trim()}>
${t('settings.node_key_submit')}
</button>
</div>
+ ${nodeKeys !== null && nodeKeyLimit > 0 && html`
+ <p class="settings-hint node-keys-count">
+ ${atLimit ? t('settings.node_keys_at_limit', { limit: nodeKeyLimit })
+ : t('settings.node_keys_count', { n: nodeKeys.length, limit: nodeKeyLimit })}
+ </p>`}
${nodeKeyStatus && html`
- <p style="margin-top:6px;font-size:0.85em;color:${[t('settings.node_key_success'), t('settings.node_key_unlinked')].includes(nodeKeyStatus) ? 'var(--success)' : 'var(--error)'}">
- ${nodeKeyStatus}
- </p>
- `}
+ <p class=${`node-keys-status ${nodeKeyStatus.ok ? 'ok' : 'error'}`} role="status">
+ ${nodeKeyStatus.text}
+ </p>`}
</div>
<div class="settings-section">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index a14bdc3..07780a2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -3782,6 +3782,48 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
overflow-wrap: anywhere;
margin-right: 8px;
}
+/* Profile → the account's linked nodes, one row per machine. The actions wrap
+ under the key on a phone rather than squeezing it: the key is what a person
+ compares against `meshbay-node status`. */
+.node-keys { list-style: none; margin: 4px 0 0; padding: 0; }
+.node-keys-item {
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
+ padding: 10px 0; border-top: 1px solid var(--border);
+}
+.node-keys-item:first-child { border-top: none; }
+.node-keys-dot {
+ flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
+ background: var(--text-dim);
+}
+.node-keys-dot.on { background: var(--success); }
+.node-keys-main { flex: 1 1 200px; min-width: 0; }
+.node-keys-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.node-keys-key { font-size: 0.85em; color: var(--text); overflow-wrap: anywhere; }
+.node-keys-badge {
+ font-size: 0.72em; padding: 1px 7px; border-radius: 999px;
+ background: var(--accent-bg); color: var(--accent); white-space: nowrap;
+}
+.node-keys-meta {
+ margin-top: 2px; font-size: 0.8em; color: var(--text-secondary);
+ overflow-wrap: anywhere;
+}
+.node-keys-actions { display: flex; gap: 8px; margin-left: auto; }
+.node-keys-empty { font-size: 0.9em; color: var(--text-secondary); margin: 4px 0 0; }
+.node-keys-add { display: flex; gap: 8px; margin-top: 12px; align-items: center; }
+/* A whole key fits: 44 characters, wider than .admin-search's search-box cap. */
+.node-keys-input {
+ flex: 1 1 auto; min-width: 0; max-width: 46ch;
+ font-family: monospace; font-size: 0.85em;
+}
+.node-keys-count { margin: 6px 0 0; }
+.node-keys-status { margin-top: 6px; font-size: 0.85em; }
+.node-keys-status.ok { color: var(--success); }
+.node-keys-status.error { color: var(--error); }
+@media (max-width: 480px) {
+ .node-keys-actions { margin-left: 18px; }
+ .node-keys-add { flex-wrap: wrap; }
+ .node-keys-add .admin-btn { flex: 1 1 100%; }
+}
/* Audit rows: keep the fixed-shape columns on one line (the IP must never be
clipped); only the free-text detail column wraps. */
.node-table-audit td,
diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py
index 632c133..2673df6 100644
--- a/packages/meshbay-hub/tests/test_account_deletion.py
+++ b/packages/meshbay-hub/tests/test_account_deletion.py
@@ -49,7 +49,6 @@ async def test_owner_can_delete_their_account(client, db_session):
assert user.username.startswith("deleted-")
assert user.email == ""
assert user.pw_hash == b""
- assert user.pk_node_ed25519 is None
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py
index fb05f9b..7b51d14 100644
--- a/packages/meshbay-hub/tests/test_node_auth.py
+++ b/packages/meshbay-hub/tests/test_node_auth.py
@@ -225,9 +225,11 @@ async def test_node_scope_allows_pubkey_lookup(client):
r = await client.get("/v1/users/op4_test/pubkeys",
headers={"Authorization": f"Bearer {node_token}"})
assert r.status_code == 200
- # An account id and the node's linking key — no user identity keys (H3).
+ # An account id and no user identity keys (H3). Its own latest node key, for
+ # the desktop applications already installed; nobody else sees it.
assert "pk_ed25519" not in r.json()
- assert r.json()["pk_node_ed25519"] is not None
+ assert r.json()["username"] == "op4_test"
+ assert r.json()["pk_node_ed25519"]
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_node_keys_several.py b/packages/meshbay-hub/tests/test_node_keys_several.py
new file mode 100644
index 0000000..8334ff8
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_node_keys_several.py
@@ -0,0 +1,440 @@
+"""
+One account, several nodes: one linked key per machine the person runs a node on.
+
+The hub used to keep one node key per account, so linking a second machine
+unlinked the first, which then failed its next sign-in with nothing said. These
+pin the rules that replaced it: every linked key signs in, unlinking one cuts
+that machine off at once and no other, a node token can neither link nor unlink,
+and no other account learns which keys an account has.
+"""
+
+import base64
+import json
+import time
+from pathlib import Path
+
+import pytest
+from alembic import command
+from alembic.config import Config
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from sqlalchemy import create_engine, text
+
+HUB = Path(__file__).resolve().parents[1]
+
+
+def _keypair():
+ sk = Ed25519PrivateKey.generate()
+ pk = sk.public_key().public_bytes(serialization.Encoding.Raw,
+ serialization.PublicFormat.Raw)
+ return sk, base64.b64encode(pk).decode()
+
+
+def _h(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def _account(client, username):
+ _, pk_ed = _keypair()
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local",
+ "password": "testpass99", "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_ed,
+ })
+ assert r.status_code == 201, r.text
+ r = await client.post("/v1/users/login", json={
+ "username": username, "password": "testpass99"})
+ assert r.status_code == 200, r.text
+ return r.json()["access_token"]
+
+
+async def _link(client, token, pk):
+ return await client.put("/v1/users/me/node_key", json={"pk_node_ed25519": pk},
+ headers=_h(token))
+
+
+async def _unlink(client, token, pk):
+ return await client.request("DELETE", "/v1/users/me/node_key",
+ json={"pk_node_ed25519": pk}, headers=_h(token))
+
+
+async def _node_auth(client, username, sk):
+ ts = int(time.time())
+ sig = sk.sign(f"meshbay:node_auth:{username}:{ts}".encode())
+ return await client.post("/v1/nodes/auth", json={
+ "username": username, "timestamp": ts,
+ "signature": base64.b64encode(sig).decode()})
+
+
+async def _announce(client, node_token, user_id, sk, pk):
+ ts = int(time.time())
+ sig = sk.sign(f"meshbay:node_announce:{user_id}:{pk}:{ts}".encode())
+ r = await client.post("/v1/nodes/announce", json={
+ "pk_node": pk, "timestamp": ts, "signature": base64.b64encode(sig).decode()},
+ headers=_h(node_token))
+ assert r.status_code == 201, r.text
+ return r.json()["node_id"]
+
+
+def _claims(token):
+ body = token.split(".")[1]
+ return json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
+
+
+async def _two_nodes(client, username):
+ token = await _account(client, username)
+ sk_a, pk_a = _keypair()
+ sk_b, pk_b = _keypair()
+ assert (await _link(client, token, pk_a)).status_code == 200
+ assert (await _link(client, token, pk_b)).status_code == 200
+ return token, (sk_a, pk_a), (sk_b, pk_b)
+
+
+class _FakeSocket:
+ def __init__(self):
+ self.sent, self.closed = [], None
+
+ async def send_text(self, data):
+ self.sent.append(json.loads(data))
+
+ async def close(self, code=1000):
+ self.closed = code
+
+
+# ── Two machines, one account ────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_two_nodes_of_one_account_both_sign_in(client):
+ _, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "twonodes")
+
+ ra = await _node_auth(client, "twonodes", sk_a)
+ rb = await _node_auth(client, "twonodes", sk_b)
+ assert ra.status_code == 200, ra.text
+ assert rb.status_code == 200, rb.text
+ # Each token names the key that signed in, which is what unlinking cuts.
+ assert _claims(ra.json()["access_token"])["nk"] == pk_a
+ assert _claims(rb.json()["access_token"])["nk"] == pk_b
+
+
+@pytest.mark.asyncio
+async def test_linking_a_second_key_keeps_the_first(client):
+ token, (_, pk_a), (_, pk_b) = await _two_nodes(client, "keepsfirst")
+ r = await client.get("/v1/users/me/node_keys", headers=_h(token))
+ assert r.status_code == 200
+ assert [k["pk_node_ed25519"] for k in r.json()["keys"]] == [pk_a, pk_b]
+
+
+@pytest.mark.asyncio
+async def test_an_unlinked_key_is_told_to_link_not_to_register(client):
+ """The daemon reads "No node key" to wait for its link (`waiting_for_node_key`)."""
+ token = await _account(client, "notlinked")
+ _, pk_a = _keypair()
+ await _link(client, token, pk_a)
+ sk_other, _ = _keypair()
+ r = await _node_auth(client, "notlinked", sk_other)
+ assert r.status_code == 401
+ assert "No node key" in r.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_linking_the_same_key_twice_changes_nothing(client):
+ token = await _account(client, "twice_test")
+ _, pk = _keypair()
+ assert (await _link(client, token, pk)).status_code == 200
+ assert (await _link(client, token, pk)).status_code == 200
+ r = await client.get("/v1/users/me/node_keys", headers=_h(token))
+ assert len(r.json()["keys"]) == 1
+
+
+@pytest.mark.asyncio
+async def test_a_key_is_stored_in_the_spelling_nodes_announce(client):
+ """Pasted with spaces around it, it must still match the node's own announce."""
+ token = await _account(client, "spelling")
+ _, pk = _keypair()
+ r = await _link(client, token, f" {pk}\n")
+ assert r.status_code == 200
+ assert r.json()["pk_node_ed25519"] == pk
+
+
+# ── Unlinking one machine ────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_unlinking_one_node_cuts_it_off_and_leaves_the_other(client):
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "unlinkone")
+ tok_a = (await _node_auth(client, "unlinkone", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "unlinkone", sk_b)).json()["access_token"]
+
+ r = await _unlink(client, token, pk_a)
+ assert r.status_code == 200, r.text
+
+ # A's token stops working now, not in an hour…
+ r = await client.get("/v1/users/unlinkone/pubkeys", headers=_h(tok_a))
+ assert r.status_code == 401
+ # …and A cannot sign in again until it is linked again.
+ r = await _node_auth(client, "unlinkone", sk_a)
+ assert r.status_code == 401
+ assert "No node key" in r.json()["detail"]
+
+ # B is untouched.
+ r = await client.get("/v1/users/unlinkone/pubkeys", headers=_h(tok_b))
+ assert r.status_code == 200
+ assert (await _node_auth(client, "unlinkone", sk_b)).status_code == 200
+
+ # Linking A again lets it back in.
+ assert (await _link(client, token, pk_a)).status_code == 200
+ assert (await _node_auth(client, "unlinkone", sk_a)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_unlinking_closes_that_nodes_socket_only(client):
+ from meshbay_hub.api import revocation
+
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "sockets_test")
+ me = (await client.get("/v1/users/sockets_test/pubkeys", headers=_h(token))).json()["user_id"]
+ tok_a = (await _node_auth(client, "sockets_test", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "sockets_test", sk_b)).json()["access_token"]
+ id_a = await _announce(client, tok_a, me, sk_a, pk_a)
+ id_b = await _announce(client, tok_b, me, sk_b, pk_b)
+
+ ws_a, ws_b = _FakeSocket(), _FakeSocket()
+ revocation._connected_nodes[id_a] = ws_a
+ revocation._connected_nodes[id_b] = ws_b
+ try:
+ listed = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()
+ assert [k["connected"] for k in listed["keys"]] == [True, True]
+ assert all(k["last_seen"] for k in listed["keys"])
+
+ assert (await _unlink(client, token, pk_a)).status_code == 200
+ assert ws_a.closed == 4003
+ assert ws_b.closed is None
+ assert id_a not in revocation._connected_nodes
+ assert revocation._connected_nodes.get(id_b) is ws_b
+ finally:
+ revocation.forget_node(id_a)
+ revocation.forget_node(id_b)
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_registers_its_own_node_only(client):
+ """A token names its key: it cannot register the socket of the account's other machine."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "ownnode_test")
+ me = (await client.get("/v1/users/ownnode_test/pubkeys", headers=_h(token))).json()["user_id"]
+ tok_a = (await _node_auth(client, "ownnode_test", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "ownnode_test", sk_b)).json()["access_token"]
+ id_a = await _announce(client, tok_a, me, sk_a, pk_a)
+ id_b = await _announce(client, tok_b, me, sk_b, pk_b)
+
+ assert (await _authorize_node_ws(tok_a, id_a, []))[0] == id_a
+ assert (await _authorize_node_ws(tok_b, id_b, []))[0] == id_b
+ assert (await _authorize_node_ws(tok_a, id_b, []))[0] is None
+
+ await _unlink(client, token, pk_a)
+ refused, why = await _authorize_node_ws(tok_a, id_a, [])
+ assert refused is None and "no longer linked" in why
+ assert (await _authorize_node_ws(tok_b, id_b, []))[0] == id_b
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_from_before_the_claim_still_works(client):
+ """A token without `nk` predates it; it expires within the hour on its own."""
+ from meshbay_hub.auth import issue_access_token
+
+ token = await _account(client, "legacy_test")
+ me = (await client.get("/v1/users/legacy_test/pubkeys", headers=_h(token))).json()["user_id"]
+ old = issue_access_token(me, scope="node")
+ r = await client.get("/v1/users/legacy_test/pubkeys", headers=_h(old))
+ assert r.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_token_from_before_the_claim_cannot_bring_an_unlinked_node_back(client):
+ """Found in the real run: the node held its pre-upgrade token and re-registered at once."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+ from meshbay_hub.auth import issue_access_token
+
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "legacyws_test")
+ me = (await client.get("/v1/users/legacyws_test/pubkeys",
+ headers=_h(token))).json()["user_id"]
+ tok_a = (await _node_auth(client, "legacyws_test", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "legacyws_test", sk_b)).json()["access_token"]
+ id_a = await _announce(client, tok_a, me, sk_a, pk_a)
+ id_b = await _announce(client, tok_b, me, sk_b, pk_b)
+ old = issue_access_token(me, scope="node")
+
+ assert (await _authorize_node_ws(old, id_a, []))[0] == id_a
+ await _unlink(client, token, pk_a)
+ refused, why = await _authorize_node_ws(old, id_a, [])
+ assert refused is None and "no longer linked" in why
+ assert (await _authorize_node_ws(old, id_b, []))[0] == id_b
+
+
+# ── Who may link, unlink and see ─────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_node_token_cannot_link_list_or_unlink_a_sibling(client):
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "nodescope")
+ tok_a = (await _node_auth(client, "nodescope", sk_a)).json()["access_token"]
+ _, pk_evil = _keypair()
+ assert (await _link(client, tok_a, pk_evil)).status_code == 403
+ assert (await _unlink(client, tok_a, pk_b)).status_code == 403
+ r = await client.get("/v1/users/me/node_keys", headers=_h(tok_a))
+ assert r.status_code == 403
+ keys = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()["keys"]
+ assert [k["pk_node_ed25519"] for k in keys] == [pk_a, pk_b]
+
+
+@pytest.mark.asyncio
+async def test_a_node_may_unlink_itself(client):
+ """`meshbay-node setup --reset` and the Node page: the node lets go of its own key."""
+ token, (sk_a, pk_a), (sk_b, pk_b) = await _two_nodes(client, "selfunlink")
+ tok_a = (await _node_auth(client, "selfunlink", sk_a)).json()["access_token"]
+ tok_b = (await _node_auth(client, "selfunlink", sk_b)).json()["access_token"]
+
+ assert (await _unlink(client, tok_a, pk_a)).status_code == 200
+ assert (await _node_auth(client, "selfunlink", sk_a)).status_code == 401
+
+ # A node from before the key was named in the request asks with no body.
+ r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(tok_b))
+ assert r.status_code == 200, r.text
+ assert r.json()["pk_node_ed25519"] == pk_b
+ keys = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()["keys"]
+ assert keys == []
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_without_its_key_cannot_unlink(client):
+ from meshbay_hub.auth import issue_access_token
+
+ token, (_, pk_a), _ = await _two_nodes(client, "legacyunl")
+ me = (await client.get("/v1/users/legacyunl/pubkeys", headers=_h(token))).json()["user_id"]
+ old = issue_access_token(me, scope="node")
+ assert (await _unlink(client, old, pk_a)).status_code == 403
+ r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(old))
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_another_account_cannot_unlink_or_see_my_keys(client):
+ token, (sk_a, pk_a), _ = await _two_nodes(client, "victim_test")
+ mallory = await _account(client, "mallory_test")
+
+ r = await _unlink(client, mallory, pk_a)
+ assert r.status_code == 404
+ assert (await _node_auth(client, "victim_test", sk_a)).status_code == 200
+
+ r = await client.get("/v1/users/victim_test/pubkeys", headers=_h(mallory))
+ assert r.status_code == 200
+ assert "pk_node_ed25519" not in r.json()
+ assert "keys" not in r.json()
+ mine = (await client.get("/v1/users/me/node_keys", headers=_h(mallory))).json()
+ assert mine["keys"] == []
+
+
+@pytest.mark.asyncio
+async def test_installed_desktop_apps_still_see_their_own_latest_key(client):
+ """Their interface shows the Node entries from `/pubkeys`; only for yourself."""
+ token, (_, pk_a), (_, pk_b) = await _two_nodes(client, "compatapp_test")
+ r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
+ assert r.json()["pk_node_ed25519"] == pk_b
+ await _unlink(client, token, pk_b)
+ r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
+ assert r.json()["pk_node_ed25519"] == pk_a
+ await _unlink(client, token, pk_a)
+ r = await client.get("/v1/users/compatapp_test/pubkeys", headers=_h(token))
+ assert "pk_node_ed25519" not in r.json()
+
+
+@pytest.mark.asyncio
+async def test_the_same_key_on_another_account_signs_in_only_as_that_account(client):
+ """Linking someone's public key gains nothing: the signature names the username."""
+ alice = await _account(client, "keyowner")
+ bob = await _account(client, "keycopier")
+ sk, pk = _keypair()
+ await _link(client, alice, pk)
+ await _link(client, bob, pk)
+ await _unlink(client, bob, pk)
+ assert (await _node_auth(client, "keyowner", sk)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_unlinking_a_key_that_is_not_linked_says_so(client):
+ token = await _account(client, "nothing_test")
+ _, pk = _keypair()
+ assert (await _unlink(client, token, pk)).status_code == 404
+ r = await client.request("DELETE", "/v1/users/me/node_key", headers=_h(token))
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_the_limit_is_the_announce_limit(client):
+ from meshbay_hub.api.nodes import MAX_NODES_PER_ACCOUNT
+
+ token = await _account(client, "manynodes")
+ keys = [_keypair()[1] for _ in range(MAX_NODES_PER_ACCOUNT)]
+ for pk in keys:
+ assert (await _link(client, token, pk)).status_code == 200
+ r = await _link(client, token, _keypair()[1])
+ assert r.status_code == 409
+ assert "Unlink one" in r.json()["detail"]
+ # Linking one already there still answers, at the limit.
+ assert (await _link(client, token, keys[0])).status_code == 200
+ listed = (await client.get("/v1/users/me/node_keys", headers=_h(token))).json()
+ assert listed["limit"] == MAX_NODES_PER_ACCOUNT
+ assert len(listed["keys"]) == MAX_NODES_PER_ACCOUNT
+
+
+@pytest.mark.asyncio
+async def test_deleting_the_account_unlinks_every_key(client, db_session):
+ import hashlib
+
+ from meshbay_hub.db.models import NodeKey
+ from sqlalchemy import select
+
+ name, passphrase = "goneacct_test", "a-long-enough-passphrase"
+ salt = hashlib.sha256(f"meshbay:auth:v1:{name}".encode()).digest()
+ auth_key = base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", passphrase.encode(), salt, 600_000, 32)).decode()
+ r = await client.post("/v1/users/register", json={
+ "username": name, "email": f"{name}@example.com", "auth_key": auth_key})
+ assert r.status_code in (200, 201), r.text
+ token = (await client.post("/v1/users/login", json={
+ "username": name, "auth_key": auth_key})).json()["access_token"]
+ sk_a, pk_a = _keypair()
+ await _link(client, token, pk_a)
+ await _link(client, token, _keypair()[1])
+
+ r = await client.request("DELETE", "/v1/users/me", headers=_h(token),
+ json={"auth_key": auth_key})
+ assert r.status_code == 200, r.text
+ left = (await db_session.execute(select(NodeKey))).scalars().all()
+ assert left == []
+ assert (await _node_auth(client, name, sk_a)).status_code == 401
+
+
+# ── The upgrade ──────────────────────────────────────────────────────────────
+
+def test_the_upgrade_keeps_the_key_each_account_had_linked(tmp_path, monkeypatch):
+ db = tmp_path / "hub.db"
+ monkeypatch.setenv("MESHBAY_DATABASE_URL", f"sqlite+aiosqlite:///{db}")
+ cfg = Config(str(HUB / "alembic.ini"))
+ command.upgrade(cfg, "e7f8a9b0c1d2")
+
+ engine = create_engine(f"sqlite:///{db}")
+ with engine.begin() as conn:
+ for uid, pk in (("u-linked", "PKLINKED"), ("u-none", None)):
+ conn.execute(text(
+ "INSERT INTO users (id, username, email, pw_hash, pw_salt, hub_id, "
+ "pk_node_ed25519) VALUES (:id, :id, '', x'00', x'00', 'h', :pk)"),
+ {"id": uid, "pk": pk})
+
+ command.upgrade(cfg, "f8a9b0c1d2e3")
+ with engine.begin() as conn:
+ rows = conn.execute(text(
+ "SELECT user_id, pk_node_ed25519 FROM node_keys")).fetchall()
+ assert rows == [("u-linked", "PKLINKED")]
+
+ command.downgrade(cfg, "e7f8a9b0c1d2")
+ with engine.begin() as conn:
+ back = dict(conn.execute(text("SELECT id, pk_node_ed25519 FROM users")).fetchall())
+ assert back == {"u-linked": "PKLINKED", "u-none": None}
diff --git a/packages/meshbay-hub/tests/test_node_link_banner.py b/packages/meshbay-hub/tests/test_node_link_banner.py
index eef29bf..b043a5b 100644
--- a/packages/meshbay-hub/tests/test_node_link_banner.py
+++ b/packages/meshbay-hub/tests/test_node_link_banner.py
@@ -1,13 +1,13 @@
"""
A node that cannot serve the signed-in account says so, and the account can
-let go of a node from anywhere.
+let go of any of its nodes from anywhere.
-Signing in on a desktop links this machine's node to the account (main.js
-`ensureNode`), but never over a key already linked -- that would cut off the
-user's other machine -- nor a node set up for another account. Found on a real
-install (2026-10-10): the node read "Running" while the hub refused it in a
-loop, and nothing said why. And the only way to unlink was the linked
-machine's own Node page, of no use once that machine is gone.
+An account links one key per machine it runs a node on. Signing in on a
+desktop links this machine's node (main.js `ensureNode`) beside the others,
+never a node set up for another account. Found on a real install (2026-10-10):
+the node read "Running" while the hub refused it in a loop, and nothing said
+why. And the only way to unlink was the linked machine's own Node page, of no
+use once that machine is gone -- the Profile page lists every linked node.
"""
import json
@@ -33,13 +33,15 @@ def test_the_problem_is_worked_out_from_the_node_and_the_hub(tmp_path):
assert m, "node-page.js no longer has nodeLinkProblem"
mine = {"username": "alice", "hub_url": "https://hub.example/", "pk_node_ed25519": "K1"}
cases = [
- [mine, "K1", "alice", "https://hub.example"], # linked here: fine
- [mine, None, "alice", "https://hub.example"], # nothing linked: sign-in links it
- [mine, "K2", "alice", "https://hub.example"], # another node's key
- [{**mine, "username": "bob"}, "K1", "alice", "https://hub.example"],
- [mine, "K1", "alice", "https://other.example"], # set up for another hub
- [mine, "K2", "alice", ""], # browser: same-origin hub
- [None, "K2", "alice", "https://hub.example"], # node not answering
+ [mine, ["K1"], "alice", "https://hub.example"], # linked here: fine
+ [mine, ["K2", "K1"], "alice", "https://hub.example"], # linked beside another: fine
+ [mine, None, "alice", "https://hub.example"], # hub not answered yet
+ [mine, [], "alice", "https://hub.example"], # nothing linked
+ [mine, ["K2"], "alice", "https://hub.example"], # only another machine linked
+ [{**mine, "username": "bob"}, ["K1"], "alice", "https://hub.example"],
+ [mine, ["K1"], "alice", "https://other.example"], # set up for another hub
+ [mine, ["K2"], "alice", ""], # browser: same-origin hub
+ [None, ["K2"], "alice", "https://hub.example"], # node not answering
]
script = tmp_path / "case.js"
script.write_text(
@@ -49,7 +51,8 @@ def test_the_problem_is_worked_out_from_the_node_and_the_hub(tmp_path):
encoding="utf-8")
out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True)
assert json.loads(out.stdout) == [
- None, None, "other_node", "other_account", "other_account", "other_node", None]
+ None, None, None, "not_linked", "not_linked", "other_account", "other_account",
+ "not_linked", None]
def test_the_node_page_shows_the_banner_and_offers_the_link():
@@ -58,18 +61,17 @@ def test_the_node_page_shows_the_banner_and_offers_the_link():
assert "<${NodeLinkBanner} info=${nodeInfo} token=${token} username=${username}" in page
banner = src.split("function NodeLinkBanner(", 1)[1].split("\nfunction ", 1)[0]
# Asked of the hub, not remembered from sign-in.
- assert "hubFetch(`/v1/users/${encodeURIComponent(username)}/pubkeys`" in banner
- assert "nodeLinkProblem(info, linkedKey, username, HUB)" in banner
+ assert "linkedNodeKeys(token)" in banner
+ assert "nodeLinkProblem(info, linkedKeys, username, HUB)" in banner
take_over = banner.split("const takeOver = async () => {", 1)[1].split("\n };", 1)[0]
- # Replacing another machine's link is asked first, then this node's key
- # replaces it directly: node:start left a node answering "running" alone,
- # and a node signed in before the account was linked elsewhere does
- # (found clicking it on a real install). For another account node:start
- # asks itself before switching, and takeOver gets past "running".
- assert "problem === 'other_node' && !await ask(t('node.link_here_confirm'))" in take_over
+ # This node's key is added beside the account's others, so nothing is cut
+ # off and nothing needs asking. node:start left a node answering "running"
+ # alone (found clicking it on a real install). For another account
+ # node:start asks itself before switching, and takeOver gets past "running".
+ assert "ask(" not in take_over
assert "method: 'PUT', token, body: { pk_node_ed25519: nodeKey }" in take_over
assert "platform.node.start({ hubUrl: HUB, username, token, takeOver: true })" in take_over
- for key in ("node.link_other_node", "node.link_other_account", "node.link_here",
+ for key in ("node.link_not_linked", "node.link_other_account", "node.link_here",
"node.link_take_over", "node.link_linking"):
assert f"'{key}'" in banner, key
@@ -82,12 +84,30 @@ def test_node_start_switches_a_running_node_when_asked_to_take_it_over():
assert "already.status === 'running' && !(opts && opts.takeOver)" in start
-def test_the_profile_page_can_unlink_the_node():
+def test_the_profile_page_lists_every_node_and_unlinks_one():
src = _src(PROFILE_PAGE)
- unlink = src.split("const unlinkNodeKey = useCallback(async () => {", 1)[1].split("}, [", 1)[0]
- assert "if (!await ask(t('settings.node_key_unlink_confirm'))) return;" in unlink
- assert "hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token })" in unlink
- assert "setCurrentNodeKey(null)" in unlink
- # Offered only where there is a key to let go of.
- shown = src.split("${currentNodeKey && html`", 1)[1].split("`}", 1)[0]
- assert "onClick=${unlinkNodeKey}" in shown
+ assert "linkedNodeKeys(user.token)" in src
+ unlink = src.split("const unlinkNodeKey = useCallback(async (pk) => {", 1)[1]
+ unlink = unlink.split("}, [", 1)[0]
+ assert "await ask(t('settings.node_key_unlink_confirm', { key: shortNodeKey(pk) }))" in unlink
+ # The one named, never the account's whole set.
+ assert "method: 'DELETE', token: user.token, body: { pk_node_ed25519: pk }" in unlink
+ assert "filter(k => k.pk_node_ed25519 !== pk)" in unlink
+ row = src.split("${nodeKeys.map(k => html`", 1)[1].split("</li>`)}", 1)[0]
+ assert "onClick=${() => unlinkNodeKey(k.pk_node_ed25519)}" in row
+ assert "settings.node_keys_this_machine" in row
+
+
+def test_the_profile_page_says_nothing_before_the_hub_answers():
+ src = _src(PROFILE_PAGE)
+ assert "const [nodeKeys, setNodeKeys] = useState(null);" in src
+ assert "${nodeKeys !== null && nodeKeys.length === 0 && html`" in src
+
+
+def test_the_desktop_app_links_beside_other_machines():
+ main_js = (Path(__file__).resolve().parents[3] / "packages" / "meshbay-client" / "src"
+ / "main.js").read_text(encoding="utf-8")
+ ensure = main_js.split("async function ensureNode(opts) {", 1)[1].split("\n }\n", 1)[0]
+ assert "other_node" not in ensure
+ assert "/pubkeys" not in ensure
+ assert "linkNodeKeyAndAwaitRunning(" in ensure
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
index 7d96851..ca2cb25 100644
--- a/packages/meshbay-hub/tests/test_node_ws_auth.py
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -44,6 +44,11 @@ async def _make_user(client, username: str) -> dict:
async def _announce_node(client, user: dict) -> str:
+ # A node only ever reaches the socket with its key linked to the account:
+ # `/v1/nodes/auth` issues no token otherwise.
+ r = await client.put("/v1/users/me/node_key", json={"pk_node_ed25519": user["pk_ed"]},
+ headers={"Authorization": f"Bearer {user['token']}"})
+ assert r.status_code == 200, r.text
# Announce now requires proof of possession of the node key (M8).
import time as _t
ts = int(_t.time())
@@ -62,8 +67,9 @@ async def _announce_node(client, user: dict) -> str:
def _node_token(user: dict) -> str:
+ """What `/v1/nodes/auth` issues: a node token naming the key that signed in."""
from meshbay_hub.auth import issue_access_token
- return issue_access_token(user["user_id"], scope="node")
+ return issue_access_token(user["user_id"], scope="node", node_key=user["pk_ed"])
@pytest.mark.asyncio
diff --git a/packages/meshbay-hub/tests/test_sidebar_node_section.py b/packages/meshbay-hub/tests/test_sidebar_node_section.py
index 57c9e9e..c8e44ca 100644
--- a/packages/meshbay-hub/tests/test_sidebar_node_section.py
+++ b/packages/meshbay-hub/tests/test_sidebar_node_section.py
@@ -44,7 +44,7 @@ def test_the_wizard_says_so_after_each_way_it_links_one():
def test_one_failed_read_does_not_hide_the_section_for_good():
refresh = _body(APP, "const refreshNodeKey = useCallback(", "}, [user]);")
- assert "/pubkeys" in refresh
+ assert "linkedNodeKeys(user.token)" in refresh
assert not re.search(r"\.catch\(\(\)\s*=>\s*\{\s*\}\)", refresh), (
"a swallowed failure leaves hasNodeKey false until a reload")
assert "setTimeout(" in refresh, "a failed read is tried again"
@@ -53,4 +53,4 @@ def test_one_failed_read_does_not_hide_the_section_for_good():
def test_the_session_effect_uses_the_same_read():
- assert APP.count("/pubkeys`") == 1, "one place decides hasNodeKey"
+ assert APP.count("linkedNodeKeys(") == 1, "one place decides hasNodeKey"