aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py42
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py43
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py101
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py65
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/invite-page.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js60
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js16
33 files changed, 561 insertions, 210 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index b4b2f4f..381378c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -329,6 +329,25 @@ async def admin_patch_user(
raise HTTPException(
status_code=422,
detail="status must be active, suspended, or revoked")
+ # Revoking is not a status write. It is signed and broadcast to every
+ # node so the account is refused there at once; PATCH would change only
+ # the hub row and leave the nodes unaware — a "revoked" that is not the
+ # revoked the design promises (§7.5). One door, and it broadcasts.
+ # (A moderator was already refused above by `privileged`; this is the
+ # admin, who is told the right door rather than given a broken one.)
+ if body.status == "revoked":
+ raise HTTPException(
+ status_code=400,
+ detail="Revoke through POST /v1/admin/revoke — it signs the "
+ "revocation and broadcasts it to every node.")
+ # Leaving `revoked` undoes a signed, node-enforced action, so it is an
+ # admin's call, never a moderator's: the nodes still deny this account
+ # from the broadcast, and a moderator flipping the hub row back to
+ # active would only disagree with them.
+ if user.status == "revoked" and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Only an admin can change a revoked account.")
user.status = body.status
log.info("User %s status changed to %s by %s",
user.username, body.status, current_user.username)
@@ -489,6 +508,29 @@ async def admin_patch_group(
raise HTTPException(
status_code=422,
detail="status must be active, suspended, or revoked")
+ # Suspend/unsuspend is a moderator's reversible, hub-only lever (§7.5).
+ # Revoke is neither: it is signed and broadcast to every node, and it is
+ # an administrative act — the same line `admin_patch_user` draws. Two
+ # gaps used to sit here: a moderator could set `revoked`, and a
+ # `revoked` set through this PATCH was never broadcast, so it behaved
+ # like `suspended` on nodes while claiming to be the signed, enforced
+ # state. Revoke has one door, `POST /v1/admin/revoke`, and it broadcasts.
+ if body.status == "revoked":
+ if not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Revoking a group requires admin rights.")
+ raise HTTPException(
+ status_code=400,
+ detail="Revoke a group through POST /v1/admin/revoke — it signs "
+ "the revocation and broadcasts it to every node.")
+ # Leaving `revoked` undoes that broadcast and is an admin's call: the
+ # nodes still enforce the revocation, and a moderator flipping the hub
+ # row back would only disagree with them.
+ if group.status == "revoked" and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Only an admin can change a revoked group.")
group.status = body.status
log.info("Group %s status changed to %s by %s",
group.name, body.status, current_user.username)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 501be9d..42f4101 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -59,16 +59,34 @@ async def get_current_user(
return user
-async def require_user_scope(
- payload: dict = Depends(_decode_token),
- current_user: User = Depends(get_current_user),
-) -> User:
- """Reject node-scoped tokens — only browser (user-scope) can mutate groups."""
+def _reject_node_scope(payload: dict) -> None:
+ """Refuse a node-scoped daemon token on a route meant for a person.
+
+ A node's authority and a hub role are **different notions**. What a node may
+ do is decided by its operator's roster pin on the node itself (NS4) and by
+ the node scope's deliberately narrow reach; being an admin or a moderator is
+ a hub role attached to a person's account. A node daemon authenticates with
+ the node key and receives a `scope:"node"` token so that the machine can
+ register, signal and host — never so that it can act as its operator on the
+ hub. When the operator's account happens to also hold a hub role, that role
+ is the *person's*, exercised from a browser with a user-scoped token, and
+ must not be reachable by a token the daemon holds in memory. So the scope
+ gate lives in one place and fronts every privileged dependency, not only
+ group mutation.
+ """
if payload.get("scope") == "node":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Node-scoped token cannot perform this operation — use browser",
)
+
+
+async def require_user_scope(
+ payload: dict = Depends(_decode_token),
+ current_user: User = Depends(get_current_user),
+) -> User:
+ """Reject node-scoped tokens — only browser (user-scope) can mutate groups."""
+ _reject_node_scope(payload)
return current_user
@@ -84,8 +102,13 @@ def user_is_moderator(user: User) -> bool:
async def require_moderator(
+ payload: dict = Depends(_decode_token),
current_user: User = Depends(get_current_user),
) -> User:
+ # A node-scoped daemon token is refused here even for a moderator's own
+ # account: the hub moderation surface (suspending accounts, reading the IP
+ # audit log, listing nodes) is the person's, not the machine's.
+ _reject_node_scope(payload)
if not user_is_moderator(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator access required")
@@ -93,8 +116,13 @@ async def require_moderator(
async def require_admin(
+ payload: dict = Depends(_decode_token),
current_user: User = Depends(get_current_user),
) -> User:
+ # Likewise: revoking accounts and groups (signed, broadcast to every node)
+ # and changing instance policy are administrative acts a person performs
+ # from a browser, never something a node token may reach.
+ _reject_node_scope(payload)
if not user_is_admin(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index cab60c8..efebc4d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -78,8 +78,13 @@ async def hub_pubkey():
# handshake refusal anyway. The operator is updating every client, node and hub
# by hand for this flag day, which is what makes that acceptable exactly once.
# The gate is in place for the next one, where it will work as intended.
-MIN_CLIENT_VERSION = "0.13.0"
-RECOMMENDED_CLIENT_VERSION = "0.13.0"
+#
+# 0.16.0 is the MNP 4.0 flag day: a client older than this presents its hub
+# session token to a node, which a 4.0 node refuses. A desktop client below
+# 0.16.0 is told to update *before* it connects rather than meeting a handshake
+# refusal it cannot read; the browser reloads this build from the hub.
+MIN_CLIENT_VERSION = "0.16.0"
+RECOMMENDED_CLIENT_VERSION = "0.16.0"
@router.get("/version")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
index f33dc6e..3c50e19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
@@ -4,14 +4,13 @@ Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3).
A link carries two secrets with two jobs. The node's code decides who gets the
group key, and it is shown to the hub only when the inviter asks the hub to mail
the link. The ticket here decides who may *reach* the node — membership,
-which is all the hub has to give (§7.1) — and it gives it to one account only:
-the one whose verified address the inviter named. A ticket that leaks, through a
-messaging service that previews links or a forwarded mail, is therefore useless
-without that mailbox.
+which is all the hub has to give (§7.1) — and it gives it to one account: the
+first to redeem it. Both halves are therefore bearer secrets, so the link can
+travel through any messaging service; what bounds a leaked one is that it works
+once, for seven days, and that the owner can cancel it.
-Stated per the design's convention: that binding holds against third parties and
-not against this hub, which verifies the addresses it compares. An active hub
-could already be anybody.
+The address is optional and binds nothing. When given, it is where the hub
+mails the link and a masked label in the owner's list.
No route here answers without an account, and nothing tells the inviter whether
an address has an account (M1): creating a link looks the same either way.
@@ -30,7 +29,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import mail
from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
-from meshbay_hub.auth import hash_email_blind
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User
@@ -40,9 +38,9 @@ redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"])
# The node holds at most as many unredeemed link codes per group; one more
# ticket here than codes there would be a link that cannot work.
MAX_OUTSTANDING_PER_GROUP = 20
-# A node's invitation lifetime is the operator's setting (7 days by default);
-# the ticket follows it, up to this.
-MAX_LIFETIME = timedelta(days=30)
+# The node issues a link's code for exactly this long; the ticket follows it,
+# and never outlives it.
+MAX_LIFETIME = timedelta(days=7)
# How long a spent link is kept before it is forgotten. The owner is not shown
# it — the person is in the group — but while the row is here, a reload or a
# second tab of the invitation page still answers the account that used it.
@@ -83,6 +81,8 @@ def _aware(when: datetime) -> datetime:
def _valid_email(v: str) -> str:
v = v.strip()
+ if not v:
+ return v
local, sep, domain = v.partition("@")
if (not sep or not local or not domain or "." not in domain.strip(".")
or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)):
@@ -91,7 +91,8 @@ def _valid_email(v: str) -> str:
class CreateLinkRequest(BaseModel):
- email: str
+ # Optional: only where the hub mails the link, and a label for the owner.
+ email: str = ""
expires_at: str
node_invite_id: str
# Only when the hub is to mail the link, since only then must it write it:
@@ -149,6 +150,8 @@ async def create_invite_link(
if not _NODE_INVITE_ID.match(body.node_invite_id):
raise HTTPException(status_code=422, detail="Not a node invitation id")
if body.send_email:
+ if not body.email:
+ raise HTTPException(status_code=422, detail="Mailing a link needs an address")
if payload.get("scope") == "node":
raise HTTPException(status_code=403,
detail="Invitation mail is sent from the interface only")
@@ -179,7 +182,7 @@ async def create_invite_link(
ticket = secrets.token_urlsafe(16)
row = GroupInviteLink(
group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket),
- email_hash=hash_email_blind(body.email), email_masked=_mask(body.email),
+ email_masked=_mask(body.email) if body.email else None,
node_invite_id=body.node_invite_id, expires_at=expires)
db.add(row)
await db.flush()
@@ -225,7 +228,7 @@ async def list_invite_links(
now = datetime.now(UTC)
return {"links": [{
"link_id": r.id,
- "email": r.email_masked,
+ "email": r.email_masked or "",
"node_invite_id": r.node_invite_id,
"created_at": _aware(r.created_at).isoformat(),
"expires_at": _aware(r.expires_at).isoformat(),
@@ -257,14 +260,10 @@ async def delete_invite_link(
async def _resolve(db: AsyncSession, ticket: str, user: User
) -> tuple[GroupInviteLink, Group]:
"""
- The link this ticket names, if this account may use it.
-
- One uniform refusal for everything that says nothing about the account —
- unknown, spent by someone else, expired, a group no longer active — and one
- distinct answer, `invite_other_account`, for the case the person can act
- on: signed in as somebody other than the address it was sent to. That
- answer names no address, and it is given only to someone holding the
- ticket, who already knows a link exists.
+ The link this ticket names, if this account may use it: any account while
+ nobody has, and afterwards only the one that did. One uniform refusal for
+ everything else — unknown, spent by someone else, expired, a group no
+ longer active.
"""
invalid = HTTPException(status_code=404, detail="invite_not_valid")
if not _TICKET.match(ticket or ""):
@@ -280,8 +279,6 @@ async def _resolve(db: AsyncSession, ticket: str, user: User
raise invalid
if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC):
raise invalid
- if not user.email_hash or user.email_hash != row.email_hash:
- raise HTTPException(status_code=403, detail="invite_other_account")
return row, group
@@ -312,8 +309,8 @@ async def redeem_invite_link(
db: AsyncSession = Depends(get_db),
):
"""
- Membership for the addressed account, once — and the same answer again for
- that account, because a second tab or a reload is the same person.
+ Membership for the first account that asks, once — and the same answer
+ again for that account, because a second tab or a reload is the same person.
"""
row, group = await _resolve(db, body.ticket, current_user)
if not row.redeemed_by:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 7478173..403f450 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -11,15 +11,54 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import 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 issue_access_token
+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
router = APIRouter(prefix="/v1/nodes", tags=["nodes"])
+
+class MnpTokenRequest(BaseModel):
+ # The base64 Ed25519 key of the node this token is for. The token is bound
+ # to it (E10), so it cannot be replayed to another node. The client knows it
+ # from `/v1/groups/{id}/nodes` before it connects.
+ node_pk: str = ""
+
+
+@router.post("/mnp-token")
+@limiter.limit("60/minute")
+async def mnp_token(
+ request: Request,
+ body: MnpTokenRequest | None = None,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Mint the short-lived token a member presents to a node in the MNP handshake.
+
+ Asked for with the member's own session token (require_user_scope, so a node
+ daemon token cannot mint one). The result carries the member's current group
+ membership, `aud=MNP_AUD` and the target node's key, so it authorises the
+ member to **that** node only and is refused by the hub API and by any other
+ node. Short-lived on purpose; the client refetches it for a new connection or
+ a reconnect, and it is checked only at the handshake, so a film already
+ playing is never interrupted by its expiry.
+
+ The hub does not verify the node key it is handed — binding the token to it
+ only *restricts* the token to whatever node holds that key, which is the one
+ the client is connecting to; a wrong key yields a token no node will accept.
+ """
+ rows = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == current_user.id))
+ group_ids = [gid for (gid,) in rows.all()]
+ return {
+ "mnp_token": issue_mnp_token(current_user.id, groups=group_ids,
+ node_pk=(body.node_pk if body else "")),
+ "expires_in": 900,
+ }
+
NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index f8cae8a..2c0b8db 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -441,6 +441,7 @@ async def notify_incoming(
body: IncomingRequest,
request: Request,
current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
):
"""
Signal a node that a client wants to connect (NAT punch coordination).
@@ -450,8 +451,18 @@ async def notify_incoming(
arbitrary node emit UDP packets to an address of their choosing — a small
reflection primitive using someone else's machine. The probe target must now be
the caller's own source address.
+
+ Like the offer relay, the caller must share an active group with the node —
+ checked **before** anything reveals whether the node is connected, so this is
+ not a liveness oracle a stranger can poll, and a stranger cannot make a node
+ punch on their behalf.
"""
from meshbay_hub.api.netutil import client_ip
+ from meshbay_hub.api.signaling import require_shared_active_group
+
+ # First, and before anything reveals whether the node is connected: a
+ # stranger cannot poll this for a node's liveness, nor make it punch.
+ await require_shared_active_group(db, node_id, current_user.id)
caller_ip = client_ip(request)
if body.peer_ip != caller_ip:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index 60d5e20..fc40204 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -102,6 +102,51 @@ def _take_offer(user_id: str, node_id: str, now: float) -> float | None:
return None
+async def require_shared_active_group(db: AsyncSession, node_id: str, user_id: str) -> None:
+ """The caller must share an **active** group with this node, or the node must
+ host an open group while public groups are on (that path *is* what a public
+ group means, so it follows the instance switch). Raises 403 with a uniform
+ message otherwise — the same answer whether the node is a member's or a
+ stranger's, and whether it is connected or not, so it is not a liveness
+ oracle for a non-member.
+
+ Membership is read from the connected-node registry, so a node hosting no
+ group shares one with nobody (AV24, AV1): the empty claim is "no groups", not
+ "all of its owner's". Both the WebRTC offer relay and the NAT-punch signal
+ call this, so they gate the same way (H6).
+ """
+ from meshbay_hub.api.revocation import _node_groups
+ node_group_ids = set(_node_groups.get(node_id, []))
+ if not node_group_ids:
+ raise HTTPException(status_code=403,
+ detail="Not a member of any group on this node")
+ shared = [gid for (gid,) in (await db.execute(
+ select(GroupMember.group_id).where(
+ GroupMember.user_id == user_id,
+ GroupMember.group_id.in_(node_group_ids),
+ ))).all()]
+ if not shared:
+ has_open = None
+ if await hub_settings.public_groups_allowed(db):
+ has_open = (await db.execute(
+ select(Group.id).where(
+ Group.id.in_(node_group_ids),
+ Group.join_policy == "open",
+ Group.status == "active",
+ ))).first()
+ if not has_open:
+ raise HTTPException(status_code=403,
+ detail="Not a member of any group on this node")
+ return
+ statuses = set((await db.execute(
+ select(Group.status).where(Group.id.in_(shared)))).scalars().all())
+ if "active" not in statuses:
+ # Report the strongest state present — "revoked" is the signed,
+ # node-enforced one; "suspended" is the reversible hub flag.
+ state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
+ raise HTTPException(status_code=403, detail=f"Group is {state}")
+
+
@router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse)
# Per address and per node, and only a coarse guard in front of authentication:
# the account's budget above is the limit that means something. 600 because an
@@ -143,58 +188,10 @@ async def webrtc_offer(
if not ws:
raise HTTPException(status_code=404, detail="Node not connected")
- # The caller must share at least one active group with the target node,
- # OR the node must host at least one open-join group (public groups admit
- # anyone — the node's MNP handshake handles authorization).
- #
- # That second path is exactly what "public groups" means, so it is gated by
- # the instance switch: with public groups off, a non-member is not brokered a
- # connection to a node just because it happens to host an open group. Members
- # of that group are unaffected — they match `shared` below.
- node_group_ids = set(_node_groups.get(node_id, []))
- # A node registered for no group shares no group with anybody, which is this
- # check's own answer — and `if node_group_ids:` used to skip the whole thing,
- # membership, group status and the public-group gate together. Since AV1 made
- # an empty claim mean "no groups" rather than "all of my owner's", that is
- # the *normal* registration of a node hosting nothing: exactly the
- # unconfigured node left running that took a group down on 2026-09-11. So the
- # machine least able to defend itself was the one any authenticated account
- # could make allocate a peer connection and gather ICE, which is H6 restored
- # in the one case AV1 made common.
- #
- # Nothing legitimate is lost by refusing here: a browser cannot complete a
- # handshake with such a node anyway — `group_id` is mandatory (M1) and a node
- # holding no group key refuses outright (NS8) — so this only declines work
- # the node would decline one step later, at its own expense.
- if not node_group_ids:
- raise HTTPException(status_code=403,
- detail="Not a member of any group on this node")
-
- result = await db.execute(
- select(GroupMember.group_id).where(
- GroupMember.user_id == current_user.id,
- GroupMember.group_id.in_(node_group_ids),
- ))
- shared = [gid for (gid,) in result.all()]
- if not shared:
- has_open = None
- if await hub_settings.public_groups_allowed(db):
- has_open = (await db.execute(
- select(Group.id).where(
- Group.id.in_(node_group_ids),
- Group.join_policy == "open",
- Group.status == "active",
- ))).first()
- if not has_open:
- raise HTTPException(status_code=403, detail="Not a member of any group on this node")
- else:
- statuses = set((await db.execute(
- select(Group.status).where(Group.id.in_(shared)))).scalars().all())
- if "active" not in statuses:
- # Report the strongest state present — "revoked" is the signed,
- # node-enforced one; "suspended" is the reversible hub flag.
- state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended")
- raise HTTPException(status_code=403, detail=f"Group is {state}")
+ # The caller must share an active group with the node (or the node must host
+ # an open group when public groups are on). One implementation, shared with
+ # the NAT-punch signal (`notify_incoming`), so both gate the same way.
+ await require_shared_active_group(db, node_id, current_user.id)
# Both refusals say when to come back, and transport.js does: a 429 here is
# the hub being busy, never the node being down.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index a994acb..7046c2f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -728,6 +728,14 @@ async def get_current_user_info(
class UpdateProfileRequest(BaseModel):
email: str | None = None
+ # Required to change the address on file. Changing it is the first step of
+ # an account takeover from a bare access token: the confirmation code goes
+ # to the new (attacker) address, and a verified address then unlocks the
+ # passphrase-reset path. A live access token is not enough for that — the
+ # passphrase is, exactly as for `change_password` and `delete_own_account`.
+ # This matters because a member hands its access token to every node it
+ # connects to (the MNP handshake), so a node operator holds one.
+ auth_key: str | None = None
@field_validator("email")
@classmethod
@@ -768,6 +776,22 @@ async def update_profile(
new_email = body.email.strip()
eh = hash_email_blind(new_email)
+ # Changing the address on file requires the passphrase, not merely a
+ # live token. Same second factor, and the same throttle, as a passphrase
+ # change or an account deletion — the hub still never sees the
+ # passphrase, only the derived auth_key.
+ if not body.auth_key:
+ raise HTTPException(
+ status_code=403,
+ detail="Changing your e-mail requires your passphrase.")
+ await _take_login_attempt(db, current_user.username)
+ if not await verify_password_off_loop(
+ body.auth_key, current_user.pw_hash, current_user.pw_salt,
+ current_user.pw_version):
+ raise HTTPException(status_code=403,
+ detail="Passphrase does not match")
+ await login_throttle.clear(db, current_user.username)
+
# How often one account may point the hub at a *different* address.
# Long, because this is the only path where a signed-in account chooses
# who receives a message, and a short delay alone still allows one
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index d038027..0d0fd95 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -25,6 +25,8 @@ from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from meshbay_common.tokens import HUB_API_AUD, MNP_AUD
+
# Argon2id parameters — versioned for gradual migration
_ARGON2_LANES = 4
_ARGON2_KEY_LEN = 32
@@ -230,17 +232,76 @@ def issue_access_token(
"exp": now + ttl,
"groups": groups or [],
"scope": scope,
+ # This is a hub-API credential. The node handshake binds MNP_AUD and
+ # refuses it, so a session token disclosed to a node opens nothing at
+ # the hub (see meshbay_common.tokens).
+ "aud": HUB_API_AUD,
+ }
+ return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA")
+
+
+def issue_mnp_token(user_id: str, groups: list[str] | None = None,
+ node_pk: str | None = None, ttl: int = 900) -> str:
+ """Issue the short-lived token a member presents to a node in the handshake.
+
+ `aud=MNP_AUD`, so it is accepted by `authorize_token` and refused by the hub
+ API. It is checked once, at the handshake, before any proof — so a short
+ lifetime does not interrupt a long transfer or a film already playing; only a
+ fresh connection or a reconnect needs a fresh one. It carries the same
+ `sub`/`groups`/`jti` the node authorises and denylists on.
+
+ `node` names the node this token is for (its base64 Ed25519 key), so it
+ cannot be replayed to another node the member also belongs to — the node
+ checks it in `authorize_token` (E10). The client knows the target node's key
+ before it connects and asks for a token bound to it.
+ """
+ if _hub_sk_pem is None:
+ raise RuntimeError("Hub keypair not loaded")
+ now = int(time.time())
+ payload = {
+ "iss": _hub_id,
+ "sub": user_id,
+ "jti": str(uuid.uuid4()),
+ "iat": now,
+ "exp": now + ttl,
+ "groups": groups or [],
+ "node": node_pk or "",
+ "scope": "user",
+ "aud": MNP_AUD,
}
return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA")
def decode_access_token(token: str) -> dict:
- """Verify and decode an access token. Raises on failure."""
+ """Verify and decode an access token. Raises on failure.
+
+ This is the **hub-API** decode. It binds `audience=HUB_API_AUD` and requires
+ `exp`, `sub` and `scope`. One Ed25519 key signs several kinds of token —
+ session tokens (aud=HUB_API_AUD), the MNP token a member presents to a node
+ (aud=MNP_AUD), revocation broadcasts (no `exp`/`sub`, handed to admins and
+ pushed to every node), and MHP federation tokens (aud=peer hub). Binding the
+ audience here means only a session token opens the hub API: an **MNP token
+ disclosed to a node cannot be replayed against the hub**, which is the whole
+ point of splitting the two (see meshbay_common.tokens). Requiring `exp`
+ refuses any hub-signed token with no expiry, and `scope` must still name one
+ of the two access scopes.
+
+ The node handshake uses its own decode (`meshbay_common.handshake`), which
+ binds `MNP_AUD` instead; the node's own self-decode of its node token passes
+ `audience=HUB_API_AUD` (hub_client.py), so both sides move together.
+ """
if _hub_pk_pem is None:
raise RuntimeError("Hub keypair not loaded")
# Clock-skew tolerance (meshbay_common.handshake.JWT_LEEWAY_SECONDS): a
# client whose clock is a little fast must still be able to call the API.
- return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60)
+ payload = jwt.decode(
+ token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60,
+ audience=HUB_API_AUD,
+ options={"require": ["exp", "sub", "scope", "aud"]},
+ )
+ if payload.get("scope") not in ("user", "node"):
+ raise jwt.InvalidTokenError("unrecognised token scope")
+ return payload
# ── Email encryption at rest ──────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py
new file mode 100644
index 0000000..ae9d2c9
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py
@@ -0,0 +1,34 @@
+"""invitation links bind no address
+
+A link is now redeemable by whichever account opens it first, so it can be sent
+through any messaging service. The address, when the inviter gives one, is only
+where the hub mails the link and a masked label in the owner's list.
+
+Revision ID: c4d5e6f7a8b9
+Revises: b2c3d4e5f6a7
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "c4d5e6f7a8b9"
+down_revision: str | Sequence[str] | None = "b2c3d4e5f6a7"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("group_invite_links") as batch:
+ batch.drop_column("email_hash")
+ batch.alter_column("email_masked", existing_type=sa.String(128), nullable=True)
+
+
+def downgrade() -> None:
+ # The bound address cannot be recovered; outstanding links are dropped.
+ op.execute("DELETE FROM group_invite_links")
+ with op.batch_alter_table("group_invite_links") as batch:
+ batch.alter_column("email_masked", existing_type=sa.String(128), nullable=False)
+ batch.add_column(sa.Column("email_hash", sa.String(64), nullable=False,
+ server_default=""))
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 51a3d47..09eb437 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -151,12 +151,12 @@ class GroupInviteLink(Base):
A link carries two secrets. The node's code decides whether someone gets the
group key, and the hub never sees it. This row decides whether someone may
- *reach* the node at all — membership, which is all the hub has to give — and
- only for the account whose verified address matches `email_hash`. The ticket
- is stored as `sha256(ticket)`, so a copy of this table opens nothing.
+ *reach* the node at all — membership, which is all the hub has to give — for
+ the first account that redeems it. The ticket is stored as `sha256(ticket)`,
+ so a copy of this table opens nothing.
- No address in the clear: `email_hash` is the same blind index `users` has,
- and `email_masked` is what the owner's list shows (`al***@ex***.com`).
+ The address is optional and binds nothing: when the inviter gave one,
+ `email_masked` is what the owner's list shows (`al***@ex***.com`).
"""
__tablename__ = "group_invite_links"
@@ -164,8 +164,7 @@ class GroupInviteLink(Base):
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), nullable=False)
created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False)
- email_hash: Mapped[str] = mapped_column(String(64), nullable=False)
- email_masked: Mapped[str] = mapped_column(String(128), nullable=False)
+ email_masked: Mapped[str | None] = mapped_column(String(128))
# The node's handle for its half, so cancelling can take back both.
node_invite_id: Mapped[str] = mapped_column(String(32), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index d36a829..b382849 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -469,7 +469,7 @@ def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None:
"\n"
f"{link}\n"
"\n"
- "It works once, and only for an account registered with this address.\n"
+ "It works once, and for seven days.\n"
"If you did not expect it, you can ignore this message.\n"
)
_send(msg, purpose="invite_link")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 2164eae..09c4acf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -344,6 +344,23 @@ function WelcomePitch() {
`;
}
+// The sign-in page's dark gradient backdrop and frosted card, without the
+// pitch beside it — so Register (and its verify/recovery/done steps) sits on
+// the same background and reads in the same dark theme as Login. A lone
+// `.welcome-side` is centred by `.welcome`'s `justify-content`.
+function AuthShell({ children }) {
+ return html`
+ <div class="page-center">
+ <div class="welcome-backdrop" aria-hidden="true"></div>
+ <div class="welcome">
+ <div class="welcome-side">
+ ${children}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
export function RegisterPage() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
@@ -358,7 +375,9 @@ export function RegisterPage() {
const [recoveryMnemonic, setRecoveryMnemonic] = useState('');
const [recoverySaved, setRecoverySaved] = useState(false);
const [recoveryCopied, setRecoveryCopied] = useState(false);
- const [emailRecovery, setEmailRecovery] = useState(true);
+ // Off by default: mailing the recovery key is opt-in — the key is shown on
+ // screen to save, and sending a copy is the user's own choice to make.
+ const [emailRecovery, setEmailRecovery] = useState(false);
const captcha = useCaptcha();
const onSubmit = async (e) => {
@@ -457,7 +476,7 @@ export function RegisterPage() {
if (phase === 'done') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.verified_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -467,7 +486,7 @@ export function RegisterPage() {
<p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`}
<a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
</div>
- </div>
+ </${AuthShell}>
`;
}
@@ -480,7 +499,7 @@ export function RegisterPage() {
} catch { /* clipboard blocked — the text is on screen to copy by hand */ }
};
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.recovery_title')}</h2>
<p style="margin-bottom:12px; color:var(--text-secondary)">
@@ -509,13 +528,13 @@ export function RegisterPage() {
${t('register.recovery_continue')}
</button>
</div>
- </div>
+ </${AuthShell}>
`;
}
if (phase === 'verify') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.success_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -538,12 +557,12 @@ export function RegisterPage() {
${t('register.resend_sent')}</span>`}
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.title')}</h2>
<form onSubmit=${onSubmit}>
@@ -585,7 +604,7 @@ export function RegisterPage() {
${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a>
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
index aac8225..15d352a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
@@ -78,7 +78,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user
const ack = await Promise.race([
transport.connect(
n.node_id, live, groupId, null, null, bundleKey,
- username, userId, null),
+ username, userId, null, undefined, undefined, n.pk_node),
new Promise((_, reject) => {
const giveUp = () => reject(new Error('Connection timeout'));
stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
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 f81247d..85fe53c 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,11 +142,15 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
const linkNodeKey = useCallback(async (pk) => {
if (!pk) return;
- try {
- await hubFetch('/v1/users/me/node_key', {
- method: 'PUT', token, body: { pk_node_ed25519: pk },
- });
- } catch { /* already linked or same key */ }
+ // `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),
+ // 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.
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'PUT', token, body: { pk_node_ed25519: pk },
+ });
}, [token]);
const detectNode = useCallback(async () => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 2ee6e05..eb012c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -433,7 +433,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
try {
ack = await transport.connect(
n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
- username, userId, joinCode, session.recoveryKey, joinNodePk);
+ username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node);
break;
} catch (e) {
lastErr = e;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 529a33d..fba8a0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -417,7 +417,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// Whether the hub mails the invitation. Checked, the hub is handed the code
// to write it into the mail — so it is the inviter's choice, remembered per
// account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it.
- const [inviteByEmail, setInviteByEmail] = useState(true);
+ // Off by default: mailing the code is opt-in, not something to do unasked.
+ const [inviteByEmail, setInviteByEmail] = useState(false);
const [error, setError] = useState('');
// Node loopback state (Electron-only)
@@ -811,7 +812,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
useEffect(() => {
hubFetch('/v1/users/me/preferences', { token })
- .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false'))
+ .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true'))
.catch(() => {});
}, [token]);
@@ -897,10 +898,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ──────────────────
//
// Two halves, in the order that leaves nothing half-made: the node's code
- // first, then the hub's ticket bound to the address; a ticket the hub then
- // refuses takes the code back with it, since a code nobody can reach the node
- // with only occupies one of the group's twenty places. The code reaches the
- // hub only when the box asks the hub to write the mail.
+ // first, then the hub's ticket; a ticket the hub then refuses takes the code
+ // back with it, since a code nobody can reach the node with only occupies one
+ // of the group's twenty places. The address is optional and binds nothing:
+ // the link is for whoever opens it first, so it can go by any messaging app.
+ // The code reaches the hub only when the box asks the hub to write the mail.
const [linkEmail, setLinkEmail] = useState('');
const [linking, setLinking] = useState(false);
const [linkError, setLinkError] = useState('');
@@ -932,7 +934,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const doCreateLink = useCallback(async (e) => {
e.preventDefault();
const email = linkEmail.trim();
- if (!email) return;
+ const mailIt = inviteByEmail && Boolean(email);
setLinking(true);
setLinkError('');
setNewLink(null);
@@ -951,8 +953,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
method: 'POST', token,
body: {
email, expires_at: node.expires_at, node_invite_id: node.invite_id,
- send_email: inviteByEmail,
- ...(inviteByEmail ? { node_pk: n, code: node.code } : {}),
+ send_email: mailIt,
+ ...(mailIt ? { node_pk: n, code: node.code } : {}),
},
});
} catch (err) {
@@ -960,7 +962,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
throw err;
}
setNewLink({
- email,
link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }),
emailStatus: ticket.email_status,
});
@@ -1004,6 +1005,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
} catch { /* the field is selectable */ }
}, [newLink]);
+ // The system share sheet, where there is one (phones mostly): the way a link
+ // reaches a messaging app without a round trip through the clipboard.
+ const canShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function';
+ const shareLink = useCallback(async () => {
+ if (!newLink) return;
+ try {
+ await navigator.share({ title: t('members.link_share_title'), url: newLink.link });
+ } catch { /* dismissed; the field and Copy are still there */ }
+ }, [newLink]);
+
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
const isOwner = Boolean(isAdmin);
@@ -1049,7 +1060,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${inviting ? '...' : t('members.invite_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
@@ -1067,13 +1078,17 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<form onSubmit=${doCreateLink}>
${newLink && html`
<div class="success-msg" style="margin-bottom:8px">
- <p>${t('members.link_ready', { email: newLink.email })}</p>
+ <p>${t('members.link_ready')}</p>
<div class="form-row">
<input type="text" readonly value=${newLink.link}
onFocus=${e => e.target.select()} />
<button class="admin-btn" type="button" onClick=${copyLink}>
${linkCopied ? t('members.link_copied') : t('members.link_copy')}
</button>
+ ${canShare && html`
+ <button class="admin-btn" type="button" onClick=${shareLink}>
+ ${t('members.link_share')}
+ </button>`}
</div>
${newLink.emailStatus === 'sent'
? html`<p style="color:var(--success)">${t('members.link_email_sent')}</p>`
@@ -1085,13 +1100,13 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<div class="form-row">
<input type="email" placeholder=${t('members.link_email_placeholder')}
value=${linkEmail} onInput=${e => setLinkEmail(e.target.value)}
- disabled=${!connected || !operatorPaired} required />
+ disabled=${!connected || !operatorPaired} />
<button class="admin-btn" type="submit"
disabled=${linking || !connected || !operatorPaired}>
${linking ? '...' : t('members.link_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
@@ -1104,7 +1119,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${links.map(l => html`
<li key=${l.link_id} style="display:flex;gap:8px;align-items:center;
flex-wrap:wrap;word-break:break-word;margin:4px 0">
- <span>${l.email}</span>
+ <span>${l.email || t('members.link_unlabelled',
+ { date: new Date(l.created_at).toLocaleDateString() })}</span>
<span style="color:var(--text-dim)">
${l.status === 'expired'
? t('members.link_status_expired')
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
index dd678aa..41b4c4c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
@@ -25,10 +25,7 @@ export function InvitePage({ user, onJoined }) {
const [error, setError] = useState('');
const refused = useCallback((err) => {
- if (err.message === 'invite_other_account') {
- // Kept: signing out and in again as the right account is the fix.
- setPhase('other');
- } else if (err.message === 'invite_not_valid') {
+ if (err.message === 'invite_not_valid') {
clearPending();
setPhase('invalid');
} else {
@@ -97,12 +94,6 @@ export function InvitePage({ user, onJoined }) {
<button class="btn btn-primary" onClick=${join}>${t('invite.join')}</button>
<button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button>
</div>`;
- } else if (phase === 'other') {
- body = html`
- <p class="error-msg">${t('invite.other_account')}</p>
- <div style="margin-top:16px">
- <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button>
- </div>`;
} else if (phase === 'invalid') {
body = html`<p class="error-msg">${t('invite.invalid')}</p>`;
} else {
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 215f596..7bd5169 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -698,10 +698,10 @@ export default {
'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.',
'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)',
'members.link_title': "Per Link einladen",
- 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.",
- 'members.link_email_placeholder': "E-Mail-Adresse",
+ 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Verschicken Sie ihn, wie Sie möchten — Messenger, SMS. Der Link funktioniert einmal, sieben Tage lang, für die Person, die ihn zuerst öffnet.",
+ 'members.link_email_placeholder': "E-Mail-Adresse (optional)",
'members.link_btn': "Link erstellen",
- 'members.link_ready': "Einladungslink für {email}:",
+ 'members.link_ready': "Einladungslink — 7 Tage gültig, einmalig verwendbar:",
'members.link_copy': "Kopieren",
'members.link_copied': "Kopiert",
'members.link_email_sent': "Der Link wurde per E-Mail gesendet.",
@@ -710,9 +710,12 @@ export default {
'members.link_status_expired': "abgelaufen",
'members.link_expires': "läuft ab am {date}",
'members.link_cancel': "Abbrechen",
+ 'members.link_share': "Teilen",
+ 'members.link_share_title': "Einladung",
+ 'members.link_unlabelled': "Link vom {date}",
'invite.title': "Sie wurden eingeladen",
'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.",
- 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.",
+ 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto, oder melden Sie sich an, falls Sie bereits eines haben.",
'invite.register': "Konto erstellen",
'invite.signin': "Anmelden",
'invite.confirm': "{inviter} lädt Sie in {group} ein.",
@@ -720,7 +723,6 @@ export default {
'invite.ignore': "Ignorieren",
'invite.open': "Gruppe öffnen",
'invite.already_member': "Sie sind bereits Mitglied von {group}.",
- 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.",
'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.",
'invite.joining': "Beitritt…",
'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.",
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 4c4cf38..0c9cb19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -814,10 +814,10 @@ export default {
'members.invite_email_failed': 'Could not send the email — share the code manually.',
'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)',
'members.link_title': "Invite by link",
- 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.",
- 'members.link_email_placeholder': "E-mail address",
+ 'members.link_hint': "For someone who may not have an account yet. Send it however you like — a messaging app, a text. It works once, for seven days, for whoever opens it first.",
+ 'members.link_email_placeholder': "E-mail address (optional)",
'members.link_btn': "Create link",
- 'members.link_ready': "Invitation link for {email}:",
+ 'members.link_ready': "Invitation link — valid 7 days, single use:",
'members.link_copy': "Copy",
'members.link_copied': "Copied",
'members.link_email_sent': "The link has been sent by e-mail.",
@@ -826,9 +826,12 @@ export default {
'members.link_status_expired': "expired",
'members.link_expires': "expires {date}",
'members.link_cancel': "Cancel",
+ 'members.link_share': "Share",
+ 'members.link_share_title': "Invitation",
+ 'members.link_unlabelled': "link created {date}",
'invite.title': "You have been invited",
'invite.none': "There is no invitation waiting in this tab. Open the link you received again.",
- 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.",
+ 'invite.signed_out': "Someone invited you to a group on this hub. Create an account, or sign in if you already have one.",
'invite.register': "Create an account",
'invite.signin': "Sign in",
'invite.confirm': "{inviter} invites you to join {group}.",
@@ -836,7 +839,6 @@ export default {
'invite.ignore': "Ignore",
'invite.open': "Open the group",
'invite.already_member': "You are already a member of {group}.",
- 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.",
'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.",
'invite.joining': "Joining…",
'invite.after_register': "Sign in to join the group you were invited to.",
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 9f5158f..1399a83 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -693,10 +693,10 @@ export default {
'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.',
'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)',
'members.link_title': "Invitar con un enlace",
- 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.",
- 'members.link_email_placeholder': "Dirección de correo",
+ 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. Envíelo como prefiera — mensajería, SMS. El enlace sirve una sola vez, durante siete días, para quien lo abra primero.",
+ 'members.link_email_placeholder': "Dirección de correo (opcional)",
'members.link_btn': "Crear enlace",
- 'members.link_ready': "Enlace de invitación para {email}:",
+ 'members.link_ready': "Enlace de invitación — válido 7 días, un solo uso:",
'members.link_copy': "Copiar",
'members.link_copied': "Copiado",
'members.link_email_sent': "El enlace se ha enviado por correo.",
@@ -705,9 +705,12 @@ export default {
'members.link_status_expired': "caducado",
'members.link_expires': "caduca el {date}",
'members.link_cancel': "Cancelar",
+ 'members.link_share': "Compartir",
+ 'members.link_share_title': "Invitación",
+ 'members.link_unlabelled': "enlace creado el {date}",
'invite.title': "Le han invitado",
'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.",
- 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.",
+ 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta, o inicie sesión si ya tiene una.",
'invite.register': "Crear una cuenta",
'invite.signin': "Iniciar sesión",
'invite.confirm': "{inviter} le invita a unirse a {group}.",
@@ -715,7 +718,6 @@ export default {
'invite.ignore': "Ignorar",
'invite.open': "Abrir el grupo",
'invite.already_member': "Ya es miembro de {group}.",
- 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.",
'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.",
'invite.joining': "Uniéndose…",
'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.",
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 c072d5b..ae278d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -696,10 +696,10 @@ export default {
'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.",
'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)',
'members.link_title': "Inviter par lien",
- 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.",
- 'members.link_email_placeholder': "Adresse e-mail",
+ 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Envoyez-le comme vous voulez — messagerie, SMS. Le lien ne sert qu’une fois, pendant sept jours, à la première personne qui l’ouvre.",
+ 'members.link_email_placeholder': "Adresse e-mail (facultative)",
'members.link_btn': "Créer le lien",
- 'members.link_ready': "Lien d’invitation pour {email} :",
+ 'members.link_ready': "Lien d’invitation — valable 7 jours, usage unique :",
'members.link_copy': "Copier",
'members.link_copied': "Copié",
'members.link_email_sent': "Le lien a été envoyé par e-mail.",
@@ -708,9 +708,12 @@ export default {
'members.link_status_expired': "expiré",
'members.link_expires': "expire le {date}",
'members.link_cancel': "Annuler",
+ 'members.link_share': "Partager",
+ 'members.link_share_title': "Invitation",
+ 'members.link_unlabelled': "lien créé le {date}",
'invite.title': "Vous êtes invité",
'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.",
- 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.",
+ 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte, ou connectez-vous si vous en avez déjà un.",
'invite.register': "Créer un compte",
'invite.signin': "Se connecter",
'invite.confirm': "{inviter} vous invite à rejoindre {group}.",
@@ -718,7 +721,6 @@ export default {
'invite.ignore': "Ignorer",
'invite.open': "Ouvrir le groupe",
'invite.already_member': "Vous êtes déjà membre de {group}.",
- 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.",
'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.",
'invite.joining': "Adhésion…",
'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.",
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 28c6a3d..f0fb0d1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -696,10 +696,10 @@ export default {
'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.",
'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)',
'members.link_title': "Invita tramite link",
- 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.",
- 'members.link_email_placeholder': "Indirizzo e-mail",
+ 'members.link_hint': "Per chi forse non ha ancora un account. Lo invii come preferisce — messaggistica, SMS. Il link funziona una sola volta, per sette giorni, per chi lo apre per primo.",
+ 'members.link_email_placeholder': "Indirizzo e-mail (facoltativo)",
'members.link_btn': "Crea link",
- 'members.link_ready': "Link d’invito per {email}:",
+ 'members.link_ready': "Link di invito — valido 7 giorni, uso singolo:",
'members.link_copy': "Copia",
'members.link_copied': "Copiato",
'members.link_email_sent': "Il link è stato inviato per e-mail.",
@@ -708,9 +708,12 @@ export default {
'members.link_status_expired': "scaduto",
'members.link_expires': "scade il {date}",
'members.link_cancel': "Annulla",
+ 'members.link_share': "Condividi",
+ 'members.link_share_title': "Invito",
+ 'members.link_unlabelled': "link creato il {date}",
'invite.title': "È stato invitato",
'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.",
- 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.",
+ 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account, o acceda se ne ha già uno.",
'invite.register': "Crea un account",
'invite.signin': "Accedi",
'invite.confirm': "{inviter} la invita a unirsi a {group}.",
@@ -718,7 +721,6 @@ export default {
'invite.ignore': "Ignora",
'invite.open': "Apri il gruppo",
'invite.already_member': "È già membro di {group}.",
- 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.",
'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.",
'invite.joining': "Adesione…",
'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.",
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 f357881..560332d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -688,10 +688,10 @@ export default {
'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。',
'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)',
'members.link_title': "リンクで招待",
- 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。",
- 'members.link_email_placeholder': "メールアドレス",
+ 'members.link_hint': "まだアカウントを持っていないかもしれない人向けです。メッセージアプリやSMSなど、お好きな方法で送ってください。リンクは7日間有効で、最初に開いた人が一度だけ使えます。",
+ 'members.link_email_placeholder': "メールアドレス(任意)",
'members.link_btn': "リンクを作成",
- 'members.link_ready': "{email} への招待リンク:",
+ 'members.link_ready': "招待リンク — 7日間有効、1回限り:",
'members.link_copy': "コピー",
'members.link_copied': "コピーしました",
'members.link_email_sent': "リンクをメールで送信しました。",
@@ -700,9 +700,12 @@ export default {
'members.link_status_expired': "期限切れ",
'members.link_expires': "{date} に期限切れ",
'members.link_cancel': "取り消す",
+ 'members.link_share': "共有",
+ 'members.link_share_title': "招待",
+ 'members.link_unlabelled': "{date} に作成したリンク",
'invite.title': "招待されています",
'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。",
- 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。",
+ 'invite.signed_out': "このハブのグループに招待されています。アカウントを作成するか、すでにお持ちならサインインしてください。",
'invite.register': "アカウントを作成",
'invite.signin': "サインイン",
'invite.confirm': "{inviter} さんが {group} への参加に招待しています。",
@@ -710,7 +713,6 @@ export default {
'invite.ignore': "無視",
'invite.open': "グループを開く",
'invite.already_member': "すでに {group} のメンバーです。",
- 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。",
'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。",
'invite.joining': "参加しています…",
'invite.after_register': "招待されたグループに参加するにはサインインしてください。",
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 64e1f00..adb94c6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -697,10 +697,10 @@ export default {
'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.',
'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)',
'members.link_title': "Uitnodigen via link",
- 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.",
- 'members.link_email_placeholder': "E-mailadres",
+ 'members.link_hint': "Voor iemand die misschien nog geen account heeft. Verstuur hem zoals u wilt — berichtenapp, sms. De link werkt één keer, zeven dagen lang, voor wie hem als eerste opent.",
+ 'members.link_email_placeholder': "E-mailadres (optioneel)",
'members.link_btn': "Link maken",
- 'members.link_ready': "Uitnodigingslink voor {email}:",
+ 'members.link_ready': "Uitnodigingslink — 7 dagen geldig, eenmalig:",
'members.link_copy': "Kopiëren",
'members.link_copied': "Gekopieerd",
'members.link_email_sent': "De link is per e-mail verstuurd.",
@@ -709,9 +709,12 @@ export default {
'members.link_status_expired': "verlopen",
'members.link_expires': "verloopt op {date}",
'members.link_cancel': "Annuleren",
+ 'members.link_share': "Delen",
+ 'members.link_share_title': "Uitnodiging",
+ 'members.link_unlabelled': "link gemaakt op {date}",
'invite.title': "U bent uitgenodigd",
'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.",
- 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.",
+ 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan, of meld u aan als u er al een hebt.",
'invite.register': "Account maken",
'invite.signin': "Aanmelden",
'invite.confirm': "{inviter} nodigt u uit voor {group}.",
@@ -719,7 +722,6 @@ export default {
'invite.ignore': "Negeren",
'invite.open': "Groep openen",
'invite.already_member': "U bent al lid van {group}.",
- 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.",
'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.",
'invite.joining': "Deelnemen…",
'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.",
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 1d5f05f..fb5a4ed 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -709,10 +709,10 @@ export default {
'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.',
'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)',
'members.link_title': "Zaproś linkiem",
- 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.",
- 'members.link_email_placeholder': "Adres e-mail",
+ 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Wyślij go, jak chcesz — komunikatorem, SMS-em. Link działa raz, przez siedem dni, dla osoby, która otworzy go pierwsza.",
+ 'members.link_email_placeholder': "Adres e-mail (opcjonalnie)",
'members.link_btn': "Utwórz link",
- 'members.link_ready': "Link zaproszenia dla {email}:",
+ 'members.link_ready': "Link z zaproszeniem — ważny 7 dni, jednorazowy:",
'members.link_copy': "Kopiuj",
'members.link_copied': "Skopiowano",
'members.link_email_sent': "Link został wysłany e-mailem.",
@@ -721,9 +721,12 @@ export default {
'members.link_status_expired': "wygasł",
'members.link_expires': "wygasa {date}",
'members.link_cancel': "Anuluj",
+ 'members.link_share': "Udostępnij",
+ 'members.link_share_title': "Zaproszenie",
+ 'members.link_unlabelled': "link utworzony {date}",
'invite.title': "Otrzymano zaproszenie",
'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.",
- 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.",
+ 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto albo zaloguj się, jeśli już je masz.",
'invite.register': "Załóż konto",
'invite.signin': "Zaloguj się",
'invite.confirm': "{inviter} zaprasza cię do {group}.",
@@ -731,7 +734,6 @@ export default {
'invite.ignore': "Ignoruj",
'invite.open': "Otwórz grupę",
'invite.already_member': "Jesteś już członkiem {group}.",
- 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.",
'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.",
'invite.joining': "Dołączanie…",
'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.",
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 c2cb35e..7bd11f6 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
@@ -695,10 +695,10 @@ export default {
'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.',
'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)',
'members.link_title': "Convidar por link",
- 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.",
- 'members.link_email_placeholder': "Endereço de e-mail",
+ 'members.link_hint': "Para alguém que talvez ainda não tenha conta. Envie como preferir — aplicativo de mensagens, SMS. O link funciona uma vez, por sete dias, para quem abrir primeiro.",
+ 'members.link_email_placeholder': "Endereço de e-mail (opcional)",
'members.link_btn': "Criar link",
- 'members.link_ready': "Link de convite para {email}:",
+ 'members.link_ready': "Link de convite — válido por 7 dias, uso único:",
'members.link_copy': "Copiar",
'members.link_copied': "Copiado",
'members.link_email_sent': "O link foi enviado por e-mail.",
@@ -707,9 +707,12 @@ export default {
'members.link_status_expired': "expirado",
'members.link_expires': "expira em {date}",
'members.link_cancel': "Cancelar",
+ 'members.link_share': "Compartilhar",
+ 'members.link_share_title': "Convite",
+ 'members.link_unlabelled': "link criado em {date}",
'invite.title': "Você foi convidado",
'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.",
- 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.",
+ 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta, ou entre se já tiver uma.",
'invite.register': "Criar uma conta",
'invite.signin': "Entrar",
'invite.confirm': "{inviter} convida você para participar de {group}.",
@@ -717,7 +720,6 @@ export default {
'invite.ignore': "Ignorar",
'invite.open': "Abrir o grupo",
'invite.already_member': "Você já é membro de {group}.",
- 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.",
'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.",
'invite.joining': "Entrando…",
'invite.after_register': "Entre para participar do grupo para o qual foi convidado.",
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 37c57aa..5509332 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
@@ -677,10 +677,10 @@ export default {
'members.invite_email_failed': '无法发送邮件——请手动分享验证码。',
'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)',
'members.link_title': "通过链接邀请",
- 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。",
- 'members.link_email_placeholder': "电子邮件地址",
+ 'members.link_hint': "适用于可能还没有账户的人。可以用任何方式发送——即时通讯应用、短信。链接仅可使用一次,有效期七天,归最先打开的人。",
+ 'members.link_email_placeholder': "电子邮件地址(可选)",
'members.link_btn': "创建链接",
- 'members.link_ready': "发给 {email} 的邀请链接:",
+ 'members.link_ready': "邀请链接——有效期 7 天,仅限一次:",
'members.link_copy': "复制",
'members.link_copied': "已复制",
'members.link_email_sent': "链接已通过电子邮件发送。",
@@ -689,9 +689,12 @@ export default {
'members.link_status_expired': "已过期",
'members.link_expires': "{date} 过期",
'members.link_cancel': "取消",
+ 'members.link_share': "分享",
+ 'members.link_share_title': "邀请",
+ 'members.link_unlabelled': "{date} 创建的链接",
'invite.title': "您收到了邀请",
'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。",
- 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。",
+ 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请创建账户,如果已有账户请登录。",
'invite.register': "创建账户",
'invite.signin': "登录",
'invite.confirm': "{inviter} 邀请您加入 {group}。",
@@ -699,7 +702,6 @@ export default {
'invite.ignore': "忽略",
'invite.open': "打开群组",
'invite.already_member': "您已经是 {group} 的成员。",
- 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。",
'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。",
'invite.joining': "正在加入…",
'invite.after_register': "登录以加入您受邀的群组。",
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 ed7a2fa..d3d06d7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -37,6 +37,10 @@ export function ProfilePage({ user, onLogout }) {
const [emailVerifyPending, setEmailVerifyPending] = useState(false);
const [emailCode, setEmailCode] = useState('');
const [emailVerifying, setEmailVerifying] = useState(false);
+ // Changing the address on file now needs the passphrase (the hub verifies the
+ // derived auth_key): a bare access token — which every node this account
+ // connects to is handed — must not be able to start an account takeover.
+ const [emailPass, setEmailPass] = useState('');
const [pinCount, setPinCount] = useState(
() => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
const [delOpen, setDelOpen] = useState(false);
@@ -232,12 +236,18 @@ export function ProfilePage({ user, onLogout }) {
const saveEmail = useCallback(async () => {
const val = emailDraft.trim();
if (!val || val === email) { setEmailEditing(false); return; }
+ if (!emailPass) return; // the Save button is disabled until it is entered
setEmailSaving(true);
setEmailStatus('');
try {
+ // The passphrase is the second factor here, exactly as for a passphrase
+ // change or an account deletion: the hub gets the derived auth_key, never
+ // the passphrase itself.
+ const authKey = await window.MeshBayKeys.deriveAuthKey(emailPass, user.username);
const resp = await hubFetch('/v1/users/me', {
- method: 'PATCH', token: user.token, body: { email: val },
+ method: 'PATCH', token: user.token, body: { email: val, auth_key: authKey },
});
+ setEmailPass('');
if (resp.email_verification_required) {
setEmailVerifyPending(true);
setEmailStatus(t('settings.email_code_sent'));
@@ -248,11 +258,13 @@ export function ProfilePage({ user, onLogout }) {
setTimeout(() => setEmailStatus(''), 3000);
}
} catch (e) {
- setEmailStatus(e.message);
+ const msg = /403|does not match/i.test(e.message)
+ ? t('settings.pw_wrong_current') : e.message;
+ setEmailStatus(msg);
} finally {
setEmailSaving(false);
}
- }, [emailDraft, email, user.token]);
+ }, [emailDraft, email, emailPass, user.token, user.username]);
const verifyEmailChange = useCallback(async () => {
if (!emailCode.trim()) return;
@@ -311,14 +323,18 @@ export function ProfilePage({ user, onLogout }) {
? html`<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="email" value=${emailDraft}
onInput=${e => setEmailDraft(e.target.value)}
- onKeyDown=${e => e.key === 'Enter' && saveEmail()}
disabled=${emailVerifyPending}
style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
${!emailVerifyPending && html`
+ <input type="password" autocomplete="current-password"
+ placeholder=${t('login.password')}
+ value=${emailPass} onInput=${e => setEmailPass(e.target.value)}
+ onKeyDown=${e => e.key === 'Enter' && saveEmail()}
+ style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
<button class="admin-btn" onClick=${saveEmail}
- disabled=${emailSaving}>${t('settings.email_save')}</button>
+ disabled=${emailSaving || !emailPass}>${t('settings.email_save')}</button>
<button class="btn-secondary" onClick=${() => {
- setEmailEditing(false); setEmailDraft(email);
+ setEmailEditing(false); setEmailDraft(email); setEmailPass('');
}}>${t('settings.cancel')}</button>
`}
</span>`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 37775d8..8433b1a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -682,7 +682,7 @@ a:hover { text-decoration: underline; }
overflow: hidden;
pointer-events: none;
background: linear-gradient(155deg,
- #86a3c4 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%);
+ #809cbc 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%);
}
/* The night-blue pool. */
.welcome-backdrop::before {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
index 6ae51d1..18ad9d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -110,7 +110,7 @@ async function rewrapAllNodes(o) {
try {
await _acWithTimeout(
tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
- o.username, o.userId, null, recoveryKey),
+ o.username, o.userId, null, recoveryKey, undefined, n.pk_node),
30000, 'connect');
if (tp.newNodeBundle) {
// No identity existed on this node — connect just minted one under
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 202db94..546d01b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -305,16 +305,15 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '3.1';
-// **Not** raised with it, and that is the whole difference between 3.0 and 3.1.
-// 3.0 was a flag day because a node older than it cannot grant the lease this
-// client opens for every download and upload, so talking to one would mean
-// every transfer failing for a reason the person cannot act on. 3.1 only adds
-// `user_blob_*`: a 3.0 node answers "unknown message type" and the client
-// stores its playlists on the next node it reaches, keeping its own copy
-// meanwhile (docs/playlists.md §6.4). Refusing every 3.0 node over a feature
-// that degrades this quietly would be the flag day nobody needed.
-const MNP_V_MIN = '3.0';
+const MNP_V = '4.0';
+// Raised with it: 4.0 is a flag day. A member now presents a short-lived
+// MNP-audience token in the handshake, not its hub session token — a node
+// older than 4.0 expected the session token, and one newer refuses it, so the
+// two cannot authenticate across the break. This is the C6 rule: no
+// compatibility branch, or the old path (a hub credential handed to a node)
+// stays reachable. The desktop client is gated by client.minimum before it
+// even connects; the browser picks up this build on reload.
+const MNP_V_MIN = '4.0';
// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
// check_version): `version_too_old` means *we* are too old for it,
@@ -651,15 +650,34 @@ class MeshBayTransport {
try { this.onConnectProgress(phase); } catch { /* the caller's problem */ }
}
+ // Fetch the short-lived token presented to a node in the handshake. It is a
+ // different credential from the session token used for hub calls: aud=MNP_AUD,
+ // useless at the hub API, so a node operator who captures it gains nothing
+ // there (see meshbay_common/tokens.py). Uses the session token to ask.
+ async _fetchNodeToken(call) {
+ const doFetch = call
+ || (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) || fetch;
+ const r = await doFetch(`${this._hubUrl}/v1/nodes/mnp-token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this._accessToken}`,
+ },
+ body: JSON.stringify({ node_pk: this._nodePkTarget || '' }),
+ });
+ if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`);
+ return (await r.json()).mnp_token;
+ }
+
async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
- userId, joinCode, recoveryKey, joinNodePk) {
+ userId, joinCode, recoveryKey, joinNodePk, nodePk) {
// Remembered for _reconnectLoop, which calls connect() again with these
// same values (plus a freshly-fetched token and the identity connect()
// itself settles on below) after the WebRTC connection is declared
// "failed" — see the pc.onconnectionstatechange handler further down.
this._connectArgs = {
nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey,
- joinNodePk,
+ joinNodePk, nodePk,
};
this._lastToken = jwtToken;
// The constructor sets this once from whatever token the caller had at
@@ -677,6 +695,10 @@ class MeshBayTransport {
this._recoveryKey = recoveryKey || null;
this._username = username || null;
this._userId = userId || null;
+ // The key of the node we mean to reach, from the hub's node list. The MNP
+ // token is bound to it (E10) so it cannot be replayed to another node. It is
+ // the *expected* key; `this.nodePk` below is the one the node then proves.
+ this._nodePkTarget = nodePk || '';
// The group this connection is for. Kept on the instance because the
// handshake is not the only thing that needs it any more: device_hello and
// the chat envelope both bind to it, and both run outside connect()'s scope.
@@ -896,11 +918,21 @@ class MeshBayTransport {
// recorded handshake_ack could be replayed by an impersonating peer.
this._nonceClient = crypto.getRandomValues(new Uint8Array(32));
+ // The member authenticates to the node with a short-lived MNP token, never
+ // its hub session token. A node operator holds whatever is presented here,
+ // and the session token opens the hub API — so presenting it would hand an
+ // operator a live credential for the member (audience-bound, see
+ // meshbay_common/tokens.py). Fetched per connect and per reconnect with the
+ // session token (`this._accessToken`), so it always carries current group
+ // membership and a fresh expiry. Signaling above still uses the session
+ // token, because that is a hub call.
+ const nodeToken = await this._fetchNodeToken(call);
+
const reply = await this._sendAndWait({
type: 'handshake',
v: MNP_V,
v_min: MNP_V_MIN,
- token: jwtToken,
+ token: nodeToken,
group_id: groupId || '',
nonce: window.MeshBayCrypto.b64encode(this._nonceClient),
});
@@ -1293,7 +1325,7 @@ class MeshBayTransport {
ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw,
this._sessionKeys, args.bundleKey, args.username,
args.userId, args.joinCode, undefined,
- args.joinNodePk);
+ args.joinNodePk, args.nodePk);
} finally {
this._inReconnectAttempt = false;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 124033e..4c9f90c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -976,6 +976,22 @@ function VideoApp({
useEffect(() => { setMode(loadViewMode()); }, [groupId]);
useEffect(() => { setFilter(''); setTypeFilter('all'); }, [groupId]);
+ // When the operator changes the node's TMDB language, the node drops its
+ // metadata cache and refetches in the new language, and — until a language
+ // is set — answers nothing at all rather than querying in English (§9.7).
+ // Tell every mounted tile to redo its media_meta_req and drop the
+ // show-level meta already merged here, so the grid switches language (or
+ // fills in for the first time, right after the operator picks one) without
+ // a page reload. Skip the initial mount: the language is already right then,
+ // and bumping would restorm TMDB on every open of the Videos tab.
+ const tmdbLanguage = tmdbConfig ? (tmdbConfig.language || '') : '';
+ const firstLangRef = useRef(true);
+ useEffect(() => {
+ if (firstLangRef.current) { firstLangRef.current = false; return; }
+ setMetaByGroup({});
+ bumpMediaMetaGeneration();
+ }, [tmdbLanguage]);
+
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
const videoEntries = availableEntries || entries;