aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
commit1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch)
treedfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /packages/meshbay-hub/src/meshbay_hub
parentadda9613fad48a68239e2dbd93321818a835c286 (diff)
downloadmeshbay-1e8eae1f3de765fcd8163716de6e5fe5aab5a034.tar.gz
feat(hub): one account, several linked nodes
An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table) instead of the one users.pk_node_ed25519; the migration carries each account's key over and the downgrade keeps the last one linked. /v1/nodes/auth accepts any linked key, and its token names the key that won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the limit; DELETE /me/node_key names the key: a browser session any of its own, a node only its own. Unlinking a key closes that node's socket and refuses its tokens at once, on REST and on the socket; the account's other nodes are untouched. /pubkeys no longer gives a node key to other accounts, and gives the caller its last linked one, which installed desktop apps read. The node reports waiting_for_node_key while its key is unlinked and signs in again as soon as the hub says so, so a key linked again is back within seconds; unlinking itself signs in first to hold a token naming its key. Profile -> Linked nodes lists each node (connected, last seen, IP, linked since, This machine in the desktop app) with Copy and Unlink, and adds a key by hand. The Node page banner, Create group and the desktop app add this machine's key instead of replacing another's. Messages that pointed at Settings -> Link Node point at Profile -> Linked nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/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
24 files changed, 649 insertions, 229 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,