diff options
Diffstat (limited to 'packages')
32 files changed, 744 insertions, 161 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index dedfeb1..992fe8a 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -269,6 +269,7 @@ def authorize_token( hosted_groups: Any | None = None, denylist: DenylistLike | None = None, require_scope: str | None = "user", + node_pk_b64: str | None = None, ) -> AuthorizedPeer: """ Everything decided from the JWT, before any proof is exchanged. @@ -276,6 +277,13 @@ def authorize_token( Raises HandshakeError with a peer-safe message. Deliberately strict about `group_id`: it used to be optional, and omitting it skipped the membership check entirely and fell back to the node's first group (M1). + + `node_pk_b64` binds the token to **this** node (E10). The MNP token names + the node it was minted for (`node` claim), so a member's token captured by + the operator of one node cannot be replayed to another node the member also + belongs to — not even to reach the pre-proof window there. A node always + passes its own key; a caller that passes `None` (a unit test not exercising + this) skips the check. """ try: decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"], @@ -294,6 +302,17 @@ def authorize_token( if require_scope is not None and decoded.get("scope", "user") != require_scope: raise HandshakeError("Wrong token scope") + # The token names the node it was minted for (`node` claim). A member's real + # client always binds it to the node it is reaching, so a token captured by + # that node's operator and replayed to a *second* node the member also uses + # is refused here — before the pre-proof window can serve anything (E10). + # A token that names no node is not refused: the hub only ever mints one for + # the authenticated requester, so an unbound token grants nothing across + # accounts, and this keeps older or non-binding callers working. + node_claim = decoded.get("node", "") + if node_pk_b64 is not None and node_claim and node_claim != node_pk_b64: + raise HandshakeError("Token is not for this node", code="wrong_node") + user_id = decoded.get("sub", "") jti = decoded.get("jti", "") if not user_id: diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py index 8f385bb..f42d597 100644 --- a/packages/meshbay-common/tests/test_handshake.py +++ b/packages/meshbay-common/tests/test_handshake.py @@ -120,6 +120,27 @@ def test_a_token_with_no_audience_is_refused(hub_key): authorize_token(no_aud, pk_pem, group_id=GROUP) +def test_a_token_bound_to_another_node_is_refused(hub_key): + """E10: a token names the node it is for. A member's token captured by one + node's operator cannot be replayed to a second node the member also uses.""" + sk_pem, pk_pem = hub_key + token_for_A = _token(sk_pem, node="node-A-pk") + # Node B (its own key is 'node-B-pk') refuses it. + with pytest.raises(HandshakeError, match="this node"): + authorize_token(token_for_A, pk_pem, group_id=GROUP, node_pk_b64="node-B-pk") + # Node A accepts it. + peer = authorize_token(token_for_A, pk_pem, group_id=GROUP, node_pk_b64="node-A-pk") + assert peer.user_id == "user-1" + + +def test_a_token_naming_no_node_is_still_accepted(hub_key): + """Lenient by design: the hub mints an unbound token only for the requester, + so it grants nothing across accounts, and older callers keep working.""" + sk_pem, pk_pem = hub_key + peer = authorize_token(_token(sk_pem), pk_pem, group_id=GROUP, node_pk_b64="node-B-pk") + assert peer.group_id == GROUP + + def test_unhosted_group_refused(hub_key): sk_pem, pk_pem = hub_key with pytest.raises(HandshakeError, match="not hosted"): diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 6205180..403f450 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -21,10 +21,18 @@ 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), ): @@ -32,16 +40,22 @@ async def mnp_token( 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 and `aud=MNP_AUD`, so it authorises the member to a node and is - refused by the hub API. 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. + 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), + "mnp_token": issue_mnp_token(current_user.id, groups=group_ids, + node_pk=(body.node_pk if body else "")), "expires_in": 900, } 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/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index e182ba0..0d0fd95 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -241,7 +241,7 @@ def issue_access_token( def issue_mnp_token(user_id: str, groups: list[str] | None = None, - ttl: int = 900) -> str: + 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 @@ -249,6 +249,11 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, 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") @@ -260,6 +265,7 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, "iat": now, "exp": now + ttl, "groups": groups or [], + "node": node_pk or "", "scope": "user", "aud": MNP_AUD, } 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 a510e63..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]); @@ -1059,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)} /> @@ -1105,7 +1106,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${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)} /> 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 f3683eb..546d01b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -663,21 +663,21 @@ class MeshBayTransport { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, - body: JSON.stringify({}), + 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 @@ -695,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. @@ -1321,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; diff --git a/packages/meshbay-hub/tests/test_incoming_membership.py b/packages/meshbay-hub/tests/test_incoming_membership.py new file mode 100644 index 0000000..708e327 --- /dev/null +++ b/packages/meshbay-hub/tests/test_incoming_membership.py @@ -0,0 +1,76 @@ +"""The NAT-punch signal is not a liveness oracle, and only a member reaches it. + +`POST /v1/nodes/{id}/incoming` used to check nothing but the caller's own +address, then reveal whether the node was connected (404 vs 504) and, with QUIC +on, make it punch. Any authenticated account could poll it for a node's liveness +and make a stranger's node emit a UDP probe. It now requires a shared active +group with the node first — the same gate the offer relay uses — checked before +anything depends on the node's connection state, so a non-member gets one uniform +403 whether the node is connected or not. +""" + +import pytest +from test_availability_between_members import ( + _add_member, + _announce_node, + _make_group, + _make_user, +) + + +def _incoming(client, node_id, user, peer_ip="1.2.3.4", peer_port=5000): + return client.post(f"/v1/nodes/{node_id}/incoming", + json={"peer_ip": peer_ip, "peer_port": peer_port}, + headers={"Authorization": f"Bearer {user['token']}"}) + + +@pytest.mark.asyncio +async def test_a_non_member_is_refused_whether_the_node_is_connected_or_not(client): + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "inc_owner") + stranger = await _make_user(client, "inc_stranger") + group_id = await _make_group(client, owner, "inc-group") + node_id = await _announce_node(client, owner) + + # Node NOT in the connected registry — the stranger gets the membership 403 + # (not the connection 404), so the answer says nothing about whether the node + # is up. The detail is what distinguishes it from the peer_ip refusal that a + # request without the gate would give. + r_off = await _incoming(client, node_id, stranger) + assert r_off.status_code == 403 + assert "member" in r_off.json()["detail"] + + # Node connected and serving the group — the stranger, not a member, still gets + # the membership 403, and never reaches the punch or the connection-state answer. + rev._connected_nodes[node_id] = object() + rev._node_groups[node_id] = [group_id] + try: + r_on = await _incoming(client, node_id, stranger) + assert r_on.status_code == 403 + assert "member" in r_on.json()["detail"] + finally: + rev._connected_nodes.pop(node_id, None) + rev._node_groups.pop(node_id, None) + + +@pytest.mark.asyncio +async def test_a_member_passes_the_membership_gate(client): + """A member is not turned away by the gate. (It then reaches the connection + check — 404 here, since no real node socket is registered — never 403.)""" + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "inc2_owner") + member = await _make_user(client, "inc2_member") + group_id = await _make_group(client, owner, "inc2-group") + await _add_member(client, owner, group_id, member) + node_id = await _announce_node(client, owner) + + rev._node_groups[node_id] = [group_id] # registered/hosted, but no live socket + try: + r = await _incoming(client, node_id, member) + # Past the membership gate: the refusal, if any, is about the connection + # or the peer address, never "not a member of any group on this node". + assert r.status_code != 403 or "member" not in r.json().get("detail", "") + finally: + rev._node_groups.pop(node_id, None) diff --git a/packages/meshbay-hub/tests/test_invite_email_choice.py b/packages/meshbay-hub/tests/test_invite_email_choice.py index e04c31f..de9410d 100644 --- a/packages/meshbay-hub/tests/test_invite_email_choice.py +++ b/packages/meshbay-hub/tests/test_invite_email_choice.py @@ -3,8 +3,9 @@ Whether the hub mails an invitation is the inviter's choice, and it is remembere Mailing it hands the hub the code — `invite-notify` writes it into the message — which is exactly what §3.4 says the code is for not doing. So the Members tab -offers it as a box, checked by default, and an unchecked box must mean the hub -is never asked. The choice lives in an account preference; a key the hub does +offers it as a box, unchecked by default (mailing the code is opt-in), and an +unchecked box must mean the hub is never asked. The choice lives in an account +preference, remembered once set; a key the hub does not list is refused, and the box would snap back on every click with nothing on screen to say why. """ diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py index 7b483ff..3aa3115 100644 --- a/packages/meshbay-hub/tests/test_mnp_token.py +++ b/packages/meshbay-hub/tests/test_mnp_token.py @@ -69,3 +69,24 @@ async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(cli # The MNP token authorises the member to the node. peer = authorize_token(mnp, pk, group_id=gid) assert peer.group_id == gid + + +@pytest.mark.asyncio +async def test_the_mnp_token_is_bound_to_the_node_it_names(client): + """E10: a token minted for node A is refused by node B, so an operator who + captures a member's token cannot replay it to another of the member's nodes.""" + from meshbay_hub.auth import hub_public_key_pem + + tok = await _session_token(client, "mnp_bind_test") + H = {"Authorization": f"Bearer {tok}"} + gid = (await client.post("/v1/groups", headers=H, json={ + "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"] + # A token bound to node A's key. + mnp = (await client.post("/v1/nodes/mnp-token", headers=H, + json={"node_pk": "node-A-pk"})).json()["mnp_token"] + pk = hub_public_key_pem() + # Node B refuses it; node A accepts it. + with pytest.raises(HandshakeError, match="this node"): + authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk") + peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk") + assert peer.group_id == gid diff --git a/packages/meshbay-node/src/meshbay_node/cli/setup.py b/packages/meshbay-node/src/meshbay_node/cli/setup.py index b2a8e83..48a55ef 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/setup.py +++ b/packages/meshbay-node/src/meshbay_node/cli/setup.py @@ -33,11 +33,6 @@ def init(args) -> None: cfg_dir = cfg_path.parent cfg_dir.mkdir(parents=True, exist_ok=True) - from meshbay_node.platform import install_node_env - env_written = install_node_env(cfg_dir) - if env_written: - print(f"Wrote {env_written} (packaged defaults).") - hub_url = args.hub_url username = args.username diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 4800dac..dc5df81 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -983,6 +983,23 @@ class NodeDaemon(EnrichmentMixin): self._config.hub.username, self._config.hub.url, ) await asyncio.sleep(5) + elif e.response.status_code in (429, 500, 502, 503, 504): + # Transient: the hub is busy (429 — often this daemon's own + # retry storm against the sign-in rate limit), restarting + # (502/503) or erroring (500/504). None of these is a reason + # to exit: the daemon exiting here crash-loops under systemd + # and strands the operator, who needs it alive to read the + # node key (`meshbay-node status`, the desktop client) so + # they can link it. Back off — respecting Retry-After when + # the hub sends one — and try again, rather than dying. + self._state["status"] = "waiting_for_hub" + delay = 10 + ra = (e.response.headers or {}).get("Retry-After") + if ra and str(ra).isdigit(): + delay = min(max(delay, int(ra)), 300) + log.warning("Hub returned %s on login — retrying in %ds", + e.response.status_code, delay) + await asyncio.sleep(delay) else: raise except Exception as e: diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 9c692ca..17262bc 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -290,6 +290,33 @@ class MediaCache: await self._db.commit() return cur.rowcount + async def clear_tmdb_metadata(self) -> int: + """ + Drop every cached TMDB fiche — show/movie details (`tmdb_meta`) and + per-season metadata (`season_meta`) — so the next `media_meta_req` + refetches each from TMDB. The file->tmdb *matches* (`file_tmdb`) are + language-independent and deliberately kept: the match is the same + title whatever language its blurb is in. + + Called when the node's TMDB *language* changes (`ops.set_tmdb_config`). + A fiche is cached under `tmdb_id` alone, on purpose — there is only + ever one node-wide language, so a per-language key would be dead + weight — which is exactly why the language it was fetched in is not + recorded, and a fiche cached under the old language would otherwise be + served unchanged for its whole 30-day TTL after the operator switched. + Wiping them on the switch is what makes the new language actually take + effect on a library that has already been browsed. Returns the number + of rows removed. + """ + if not self._db: + return 0 + cur = await self._db.execute("DELETE FROM tmdb_meta") + removed = cur.rowcount + cur = await self._db.execute("DELETE FROM season_meta") + removed += cur.rowcount + await self._db.commit() + return removed + # ── tmdb id -> metadata json ───────────────────────────────────────────── async def get_tmdb_meta(self, tmdb_id: str, media_type: str) -> dict | None: diff --git a/packages/meshbay-node/src/meshbay_node/ops/apps.py b/packages/meshbay-node/src/meshbay_node/ops/apps.py index 0e4f6dd..fbd984d 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/apps.py +++ b/packages/meshbay-node/src/meshbay_node/ops/apps.py @@ -53,6 +53,13 @@ async def set_tmdb_config(state: dict, token: str | None = None, `language`. """ roster = _roster(state) + # Whether the *language* actually changes decides whether the cached + # fiches must go (below) — read the old value before overwriting it. + # "" (default/English) and None (unset) are the same language here. + language_changed = False + if language is not None: + _, old_language = await roster.tmdb_config() + language_changed = (language or None) != (old_language or None) await roster.set_tmdb_config(token, language, set_by=state.get("node_user_id", "")) # `token=None` means "leave whatever was there" (§ set_tmdb_config's own # docstring) — so the customized flag only changes when a value (a real @@ -61,6 +68,19 @@ async def set_tmdb_config(state: dict, token: str | None = None, state["tmdb_token_customized"] = bool(token) if language is not None: state["tmdb_language"] = language + # A cached TMDB fiche is stored under its tmdb_id alone and carries no note + # of the language it was fetched in (there is only one node-wide language), + # so changing the language leaves every fiche stale for its 30-day TTL. + # Drop the metadata cache here so the next media_meta_req refetches in the + # new language — this is what makes the setting take on a library that was + # already browsed, instead of the operator having to find a cache to clear. + # The matches (file_tmdb) are language-independent and kept. + if language_changed: + media_cache = state.get("media_cache") + if media_cache is not None: + removed = await media_cache.clear_tmdb_metadata() + log.info("TMDB language changed to %s: cleared %d cached fiche(s)", + language or "(default)", removed) log.info("TMDB config: custom_token=%s language=%s", bool(token), language or state.get("tmdb_language", "")) return { diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py index 7e840ad..7db251b 100644 --- a/packages/meshbay-node/src/meshbay_node/platform.py +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -80,9 +80,10 @@ def state_dir() -> Path: def packaged_default_env() -> Path | None: """ The `default.env` shipped with the package: build-time defaults, currently - the shared read-only TMDB token. `init` copies it to config_dir()/node.env - and nothing reads it in place, so an operator's edits to their own copy - survive an upgrade. + the shared read-only TMDB token. The daemon reads it in place, beneath + <config>/node.env, so it reaches every node however that node was set up -- + `meshbay-node init` and the desktop client's onboarding alike -- and an + operator's own node.env still wins. Frozen (PyInstaller/Windows): beside the executable, where build-node-runtime.ps1 puts it -- the same placement it uses for ffmpeg. @@ -102,39 +103,7 @@ def packaged_default_env() -> Path | None: return None -def install_node_env(target_dir: Path) -> Path | None: - """ - Copy the packaged default.env to <target_dir>/node.env, once, at init. - - Never overwrites: an existing node.env holds the operator's own values, and - silently replacing a configured token with the packaged one would be worse - than doing nothing. Returns the path when written, None when there was - nothing to copy or a file was already there. - """ - src = packaged_default_env() - if src is None: - return None - dest = target_dir / "node.env" - if dest.exists(): - return None - dest.write_bytes(src.read_bytes()) - chmod_private(dest) - return dest - - -def load_node_env(source_dir: Path) -> int: - """ - Read <source_dir>/node.env into os.environ, returning how many names were - set. - - systemd does this on Linux through `EnvironmentFile=`, but the Windows - autostart is a Startup-folder .vbs with no equivalent, so the daemon reads - the file itself and both platforms behave the same. An existing environment - variable always wins -- an operator exporting a value, or systemd having - already loaded the same file, overrides the packaged default rather than - being overridden by it. - """ - path = source_dir / "node.env" +def _load_env_file(path: Path) -> int: try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): @@ -154,6 +123,29 @@ def load_node_env(source_dir: Path) -> int: return count +def load_node_env(source_dir: Path) -> int: + """ + Read <source_dir>/node.env, then the packaged default.env, into + os.environ, returning how many names were set. + + systemd does the first through `EnvironmentFile=`, but the Windows + autostart is a Startup-folder .vbs with no equivalent, so the daemon reads + the file itself and both platforms behave the same. An existing environment + variable always wins, and node.env wins over the packaged default -- an + operator exporting a value, or systemd having already loaded the same file, + overrides the packaged default rather than being overridden by it. + + The packaged default used to reach a node only as a copy made by + `meshbay-node init`; a node onboarded by the desktop client never ran it and + ran with no TMDB token at all. + """ + count = _load_env_file(source_dir / "node.env") + packaged = packaged_default_env() + if packaged is not None: + count += _load_env_file(packaged) + return count + + # ── File permissions ───────────────────────────────────────────────────────── diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index 96cd752..715448f 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -286,6 +286,7 @@ class _MNPServerProtocol(QuicConnectionProtocol): group_id=msg.get("group_id", ""), hosted_groups=self._ctx.get("groups"), denylist=self._ctx.get("denylist"), + node_pk_b64=pk_to_b64(self._ctx["sk_node"].public_key()), ) except HandshakeError as refusal: self._send(stream_id, {"type": "error", "detail": str(refusal), diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py index 9222ad8..834bac4 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py @@ -169,6 +169,15 @@ class VideoMetaMixin: await media_cache.put_thumb(thumb_hash, synthetic_id, content) return thumb_hash + def _tmdb_language(self) -> str: + """ + The node-wide TMDB query language, or "" when the operator has not + chosen one yet. Read from the live daemon state (kept current by + tmdb_config_ack, same source the handshake ack reads), not the DB, so + it is a cheap in-memory lookup on the hot metadata path. + """ + return (self._ctx.get("daemon_state") or {}).get("tmdb_language") or "" + async def _do_media_meta_request(self, msg: dict) -> None: """ docs/MESHBAY_DESIGN.md §9.7: TMDB metadata for one file, resolved from @@ -250,6 +259,19 @@ class VideoMetaMixin: self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, "file_id": file_id, "confidence": 0}) return + if not self._tmdb_language(): + # No query language chosen yet: hold off entirely rather than + # search now. TMDB would answer in its English default, which + # is both the wrong language and a wasted call — the whole + # library fetched now would be thrown away and refetched the + # moment a language is set, doubling the request count against + # TMDB's rate limit. Waiting until the operator has chosen one + # is what makes the first (and only) fetch the chosen language + # (docs/MESHBAY_DESIGN.md §9.7). The client refetches on the + # tmdb_config_ack that carries the new language. + self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, + "file_id": file_id, "confidence": 0}) + return result, ratio = await self._tmdb_search(tmdb_client, entry, is_show) if result is None or ratio < 0.6: self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, @@ -315,6 +337,15 @@ class VideoMetaMixin: details = await media_cache.get_season_meta(tmdb_id, season) if details is None: + if not self._tmdb_language(): + # Same gate as media_meta_req above: no query language yet + # means no TMDB call (docs/MESHBAY_DESIGN.md §9.7). A show is + # only matched once a language is set, so this is normally + # unreachable, but a client holding a tmdb_id from an earlier + # session must not reopen an English fetch either. + self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION, + "tmdb_id": tmdb_id, "season": season, "confidence": 0}) + return fetched = await tmdb_client.tv_season(tmdb_id, season) if fetched is None: self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py index 87394d1..eceb2b4 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py @@ -65,6 +65,7 @@ class HandshakeMixin: group_id=group_id, hosted_groups=self._ctx.get("groups"), denylist=self._ctx.get("denylist"), + node_pk_b64=self._node_pk_b64(), ) except HandshakeError as refusal: # HandshakeError messages are authored to be peer-safe, unlike arbitrary diff --git a/packages/meshbay-node/tests/test_login_retry_is_resilient.py b/packages/meshbay-node/tests/test_login_retry_is_resilient.py new file mode 100644 index 0000000..e37a413 --- /dev/null +++ b/packages/meshbay-node/tests/test_login_retry_is_resilient.py @@ -0,0 +1,109 @@ +"""A transient hub state on node sign-in must not crash the daemon. + +`_login_with_retry` retries a 401 (the node key is not linked yet — a human has +to link it, and the daemon must stay alive so its key can be read). It used to +`raise` on every other status, so a **429** (the daemon's own retries hitting +the sign-in rate limit) or a **502/503** (the hub restarting during a deploy) +killed the process — systemd then crash-looped it, which is what "impossible de +démarrer le node" looked like after a reset left the node with a fresh, unlinked +key. Those transient statuses are now retried with a back-off that respects +`Retry-After`. +""" + +import asyncio + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node.config import Config, GroupConfig, HubConfig, KeystoreConfig, NodeConfig +from meshbay_node.daemon import NodeDaemon + + +def _daemon(tmp_path): + cfg = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=29011, ui_port=29012), + groups=[], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + return NodeDaemon(cfg) + + +def _http_error(status: int, headers: dict | None = None) -> httpx.HTTPStatusError: + req = httpx.Request("POST", "http://localhost:9999/v1/nodes/auth") + resp = httpx.Response(status, headers=headers or {}, request=req) + return httpx.HTTPStatusError(f"{status}", request=req, response=resp) + + +class _Hub: + """A hub whose `startup` raises the given sequence, then returns a session.""" + def __init__(self, seq): + self._seq = list(seq) + self.calls = 0 + + async def startup(self, endpoint_hint=None): + self.calls += 1 + item = self._seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [429, 500, 502, 503, 504]) +async def test_a_transient_status_is_retried_not_fatal(tmp_path, status, monkeypatch): + slept = [] + + async def _sleep(d): + slept.append(d) + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + session = object() + hub = _Hub([_http_error(status), session]) # transient, then success + got = await daemon._login_with_retry(hub) + assert got is session # it recovered instead of crashing + assert hub.calls == 2 # retried once + assert slept # it backed off + + +@pytest.mark.asyncio +async def test_retry_after_is_respected(tmp_path, monkeypatch): + slept = [] + + async def _sleep(d): + slept.append(d) + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + hub = _Hub([_http_error(429, {"Retry-After": "42"}), object()]) + await daemon._login_with_retry(hub) + assert 42 in slept + + +@pytest.mark.asyncio +async def test_a_401_still_retries_and_stays_alive(tmp_path, monkeypatch): + async def _sleep(d): + pass + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + session = object() + hub = _Hub([_http_error(401), session]) + got = await daemon._login_with_retry(hub) + assert got is session + assert daemon._state.get("status") in ("waiting_for_node_key", "waiting_for_account") + + +@pytest.mark.asyncio +async def test_a_genuine_client_error_still_raises(tmp_path, monkeypatch): + """A 400/422 is a bug, not a transient state — it must not be swallowed.""" + async def _sleep(d): + pass + monkeypatch.setattr(asyncio, "sleep", _sleep) + + daemon = _daemon(tmp_path) + hub = _Hub([_http_error(400), object()]) + with pytest.raises(httpx.HTTPStatusError): + await daemon._login_with_retry(hub) diff --git a/packages/meshbay-node/tests/test_media_meta_request.py b/packages/meshbay-node/tests/test_media_meta_request.py index 9a1c5aa..0392845 100644 --- a/packages/meshbay-node/tests/test_media_meta_request.py +++ b/packages/meshbay-node/tests/test_media_meta_request.py @@ -64,6 +64,9 @@ def _session(index, media_cache, tmdb_client): "media_cache": media_cache, "tmdb_client": tmdb_client, "tmdb_enabled": True, + # A configured node: the language gate (§9.7) holds every TMDB fetch + # until a language is chosen, so these routing tests must set one. + "daemon_state": {"tmdb_language": "en-US"}, } session._group_id = None session.sent = [] diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index 5c80c5b..bebe8d9 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -427,39 +427,8 @@ def test_source_checkout_has_no_packaged_default(monkeypatch, tmp_path): assert plat.packaged_default_env() is None -def test_install_node_env_copies_once(monkeypatch, tmp_path): - src = tmp_path / "default.env" - src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJfirst\n") - monkeypatch.setattr(plat, "packaged_default_env", lambda: src) - cfg = tmp_path / "config" - cfg.mkdir() - - written = plat.install_node_env(cfg) - assert written == cfg / "node.env" - assert "eyJfirst" in written.read_text() - - -def test_install_node_env_never_overwrites_operator_values(monkeypatch, tmp_path): - """An existing node.env holds the operator's own token; clobbering it would - silently downgrade a configured node to the shared default.""" - src = tmp_path / "default.env" - src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") - monkeypatch.setattr(plat, "packaged_default_env", lambda: src) - cfg = tmp_path / "config" - cfg.mkdir() - (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n") - - assert plat.install_node_env(cfg) is None - assert "eyJoperator" in (cfg / "node.env").read_text() - - -def test_install_node_env_is_a_noop_without_a_package(monkeypatch, tmp_path): - monkeypatch.setattr(plat, "packaged_default_env", lambda: None) - assert plat.install_node_env(tmp_path) is None - assert not (tmp_path / "node.env").exists() - - def test_load_node_env_sets_names(monkeypatch, tmp_path): + monkeypatch.setattr(plat, "packaged_default_env", lambda: None) (tmp_path / "node.env").write_text( "# a comment\n" "\n" @@ -482,5 +451,33 @@ def test_load_node_env_does_not_override_the_environment(monkeypatch, tmp_path): assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJfromenv" -def test_load_node_env_tolerates_a_missing_file(tmp_path): +def test_load_node_env_tolerates_a_missing_file(monkeypatch, tmp_path): + monkeypatch.setattr(plat, "packaged_default_env", lambda: None) assert plat.load_node_env(tmp_path) == 0 + + +def test_load_node_env_reads_the_packaged_default_without_a_node_env(monkeypatch, tmp_path): + """A node onboarded by the desktop client has no node.env: nothing ran + `init` to copy one. The packaged token must reach it anyway.""" + src = tmp_path / "default.env" + src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") + monkeypatch.setattr(plat, "packaged_default_env", lambda: src) + monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) + cfg = tmp_path / "config" + cfg.mkdir() + + assert plat.load_node_env(cfg) == 1 + assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJpackaged" + + +def test_load_node_env_prefers_the_operator_node_env(monkeypatch, tmp_path): + src = tmp_path / "default.env" + src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") + monkeypatch.setattr(plat, "packaged_default_env", lambda: src) + monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) + cfg = tmp_path / "config" + cfg.mkdir() + (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n") + + plat.load_node_env(cfg) + assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJoperator" diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py index 7e81e63..bec7df9 100644 --- a/packages/meshbay-node/tests/test_season_and_search_requests.py +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -19,7 +19,10 @@ pytestmark = pytest.mark.asyncio def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession: session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client} + # daemon_state carries a configured language: the gate (§9.7) holds every + # TMDB fetch until one is set, so these fetch/search tests must set one. + session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client, + "daemon_state": {"tmdb_language": "en-US"}} session._group_id = None # Set because production always has one: `_dispatch_message` refuses every # message until the handshake settles `_user_id`, so a session reaching any diff --git a/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py b/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py new file mode 100644 index 0000000..cf1ecdc --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py @@ -0,0 +1,80 @@ +""" +Changing the node's TMDB language wipes the cached fiches (`ops.set_tmdb_config`). + +The cache is keyed by TMDB id alone and records no language, so a fiche fetched +under the old language would be served for its whole 30-day TTL. An operator who +sets the language *after* browsing the library once — the ordinary order, since +browsing is what triggers the lazy fetch — would keep seeing the old language +otherwise (found live: a whole library indexed in English before "Français" was +chosen, 2026-09-26). Only the language change clears; a no-op re-set or a +token-only change must leave the cache alone, or every unrelated settings save +would throw the library's metadata away. +""" + +import pytest +from meshbay_node import ops +from meshbay_node.media_cache import MediaCache +from meshbay_node.roster import Roster + +pytestmark = pytest.mark.asyncio + + +async def _state(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + cache = MediaCache(db_path=tmp_path / "media_cache.db") + await cache.open() + state = {"roster": roster, "media_cache": cache, "node_user_id": "operator"} + return state, roster, cache + + +async def _seed(cache): + await cache.set_tmdb_meta("1668", "tv", {"name": "Friends"}) + await cache.set_season_meta("1668", 1, {"overview": "Season one"}) + + +async def test_changing_language_clears_the_metadata_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="") # start at default/English + await _seed(cache) + + await ops.set_tmdb_config(state, language="fr-FR") + + assert await cache.get_tmdb_meta("1668", "tv") is None + assert await cache.get_season_meta("1668", 1) is None + finally: + await roster.close() + await cache.close() + + +async def test_re_setting_the_same_language_keeps_the_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="fr-FR") + await _seed(cache) + + await ops.set_tmdb_config(state, language="fr-FR") + + assert await cache.get_tmdb_meta("1668", "tv") == {"name": "Friends"} + assert await cache.get_season_meta("1668", 1) == {"overview": "Season one"} + finally: + await roster.close() + await cache.close() + + +async def test_token_only_change_keeps_the_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="fr-FR") + await _seed(cache) + + # language=None means "leave the language" — not a language change, + # so the fiches stay. + await ops.set_tmdb_config(state, token="a-custom-token") + + assert await cache.get_tmdb_meta("1668", "tv") == {"name": "Friends"} + assert await cache.get_season_meta("1668", 1) == {"overview": "Season one"} + finally: + await roster.close() + await cache.close() diff --git a/packages/meshbay-node/tests/test_tmdb_language_gate.py b/packages/meshbay-node/tests/test_tmdb_language_gate.py new file mode 100644 index 0000000..cd43fef --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_language_gate.py @@ -0,0 +1,97 @@ +""" +The node does not query TMDB until a language is configured (§9.7). + +Browsing the Videos tab is what triggers the lazy `media_meta_req`, and it +routinely happens before the operator has opened the settings and chosen a +language. Querying then would fetch the whole library in TMDB's English default +and throw it away the moment a language was picked — double the requests against +TMDB's rate limit, for a result nobody asked for (found live 2026-09-26: a whole +library indexed in English before "Français" was chosen). So an unset language +answers confidence 0 and makes no call; a set language fetches once, in it. +""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +class CountingTmdbClient: + def __init__(self): + self.calls = 0 + + async def search_movie(self, title, year=None): + self.calls += 1 + return {"id": 42, "title": title, "release_date": "2001-01-01"}, 1.0 + + async def search_tv(self, title, year=None): + self.calls += 1 + return {"id": 43, "name": title, "first_air_date": "2001-01-01"}, 1.0 + + async def movie_details(self, tmdb_id, language=None): + self.calls += 1 + return {"title": "A Film", "genres": [{"name": "Drama"}], + "poster_path": "/p.jpg", "overview": "x"} + + async def movie_credits(self, tmdb_id): + return {"cast": [], "crew": []} + + async def fetch_image(self, url): + return b"img" + + @staticmethod + def poster_url(path): + return f"https://image.tmdb.org/t/p/w500{path}" + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +def _session(media_cache, tmdb_client, language): + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + entry = IndexEntry(id="f1", name="Some.Film.2001.mkv", path="movies", size=1, + type="video", added_at=0, display_title="Some Film") + index.add_entry(entry) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "index": index, + "media_cache": media_cache, + "tmdb_client": tmdb_client, + "tmdb_enabled": True, + "daemon_state": {"tmdb_language": language}, + } + session._group_id = None + session.sent = [] + session._send = session.sent.append + return session, entry + + +async def test_no_language_makes_no_tmdb_call(media_cache): + client = CountingTmdbClient() + session, entry = _session(media_cache, client, language="") + + await session._do_media_meta_request({"file_id": entry.id}) + + assert client.calls == 0 + assert session.sent[-1]["confidence"] == 0 + # Nothing cached, so a later request in a real language still starts clean. + assert await media_cache.get_file_tmdb(entry.id) is None + + +async def test_a_configured_language_fetches(media_cache): + client = CountingTmdbClient() + session, entry = _session(media_cache, client, language="fr-FR") + + await session._do_media_meta_request({"file_id": entry.id}) + + assert client.calls > 0 + assert session.sent[-1].get("tmdb_id") == "42" |