diff --git a/tests/test_routes_hub_slice3.py b/tests/test_routes_hub_slice3.py index f7158bf8c..4e3e097a1 100644 --- a/tests/test_routes_hub_slice3.py +++ b/tests/test_routes_hub_slice3.py @@ -275,3 +275,188 @@ async def pres_handler(method, url, **kw): assert resp.json()["endpoints"] == ["wss://x", "wss://y"] fwd = [c for c in captured if c["url"].endswith("/api/hub/presence?username=alice")] assert fwd, "presence was not forwarded to the directory" + + +# --------------------------------------------------------------------------- +# Milestone A2: friend-accept -> contact row + peer link + block cascade +# --------------------------------------------------------------------------- + + +class TestFriendAcceptContactCreation: + """Friend-accept creates a contact row and peer link.""" + + @pytest.mark.asyncio + async def test_accept_creates_contact_row(self, client, upstream): + """Friend-accept with identity info creates a contact row in contacts_store.""" + from tinyagentos.hub import identity as idmod + + # Ensure we have a local hub identity so the handler can run. + idmod.load_or_create() + + # Initialise contacts_store — the lifespan may not have done this in tests. + contacts = client._transport.app.state.contacts_store + if contacts._db is not None: + await contacts.close() + await contacts.init() + + async def handler(method, url, **kw): + return _FakeResp(content=json.dumps({ + "peer": "peerFP", + "username": "hogne", + "display_name": "Hogne", + "signing_pubkey": "ab" * 32, + "encryption_pubkey": "cd" * 32, + "endpoints": [], + }).encode()) + _set(upstream, handler) + resp = await client.post( + "/api/hub/friends/requests/rid-9/accept", + json={"peer_fingerprint": "peerFP"}, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "accepted" + + # Verify the contact row was created. + contact = await contacts.get_contact("hub:hogne") + assert contact is not None + assert contact["hub_username"] == "hogne" + assert contact["display_name"] == "Hogne" + assert contact["ed25519_pub"] == "ab" * 32 + assert contact["x25519_pub"] == "cd" * 32 + assert contact["status"] == "active" + + @pytest.mark.asyncio + async def test_accept_creates_peer_link(self, client, upstream): + """Friend-accept creates a peer_link with inbound token and endpoints.""" + from tinyagentos.hub import identity as idmod + + idmod.load_or_create() + + # Initialise contacts_store. + contacts = client._transport.app.state.contacts_store + if contacts._db is not None: + await contacts.close() + await contacts.init() + + async def handler(method, url, **kw): + return _FakeResp(content=json.dumps({ + "peer": "peerFP", + "username": "hogne", + "display_name": "Hogne", + "signing_pubkey": "ab" * 32, + "encryption_pubkey": "cd" * 32, + "endpoints": [], + }).encode()) + _set(upstream, handler) + resp = await client.post( + "/api/hub/friends/requests/rid-9/accept", + json={"peer_fingerprint": "peerFP"}, + ) + assert resp.status_code == 200 + + contacts = client._transport.app.state.contacts_store + plink = await contacts.get_peer_link("hub:hogne") + assert plink is not None + # inbound_token_hash should be set (non-empty) + assert plink.get("inbound_token_hash") + assert len(plink["inbound_token_hash"]) == 64 # SHA-256 hex + # outbound_token starts empty (filled on handshake reply) + assert plink["outbound_token"] == "" + # endpoints stored as empty list when directory returns none + assert plink["endpoints"] == [] + + @pytest.mark.asyncio + async def test_accept_no_contacts_store_does_not_crash(self, client, upstream): + """Friend-accept without a contacts_store (e.g. uninitialised) still succeeds.""" + from tinyagentos.hub import identity as idmod + + idmod.load_or_create() + + async def handler(method, url, **kw): + return _FakeResp(content=json.dumps({ + "peer": "peerFP", + "username": "alice", + }).encode()) + _set(upstream, handler) + + # Temporarily remove contacts_store to simulate missing store. + saved = client._transport.app.state.contacts_store + client._transport.app.state.contacts_store = None + try: + resp = await client.post( + "/api/hub/friends/requests/rid-9/accept", + json={"peer_fingerprint": "peerFP"}, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "accepted" + finally: + client._transport.app.state.contacts_store = saved + + +class TestBlockCascade: + """Block cascades to revoke the peer link.""" + + @pytest.mark.asyncio + async def test_block_revokes_peer_link(self, client, upstream): + """Blocking a peer whose fingerprint matches a hub author revokes their peer link.""" + from tinyagentos.hub import identity as idmod + + idmod.load_or_create() + + # Initialise contacts_store. + contacts = client._transport.app.state.contacts_store + if contacts._db is not None: + await contacts.close() + await contacts.init() + + # First accept to create the contact + peer link. + async def accept_handler(method, url, **kw): + return _FakeResp(content=json.dumps({ + "peer": "peerFP", + "username": "hogne", + "display_name": "Hogne", + "signing_pubkey": "ab" * 32, + "encryption_pubkey": "cd" * 32, + "endpoints": [], + }).encode()) + _set(upstream, accept_handler) + resp = await client.post( + "/api/hub/friends/requests/rid-1/accept", + json={"peer_fingerprint": "peerFP"}, + ) + assert resp.json()["state"] == "accepted" + + # Verify peer link exists before block. + contacts = client._transport.app.state.contacts_store + plink = await contacts.get_peer_link("hub:hogne") + assert plink is not None + assert plink.get("revoked_at") is None + + # Register the hub author so get_author() resolves fingerprint → username. + # The friend-accept handler only records the local friend edge, not an + # author row for the peer. Register one manually: fingerprint "peerFP" + # (the blocked peer) → username "hogne" so the cascade finds the contact. + from tinyagentos.hub.store import HubStore + hub = HubStore(hub_store.default_db_path()) + await hub.init() + try: + await hub.upsert_author("peerFP", username="hogne") + + # Now block — should cascade to revoke_peer_link. + captured, calls = upstream + resp = await client.post( + "/api/hub/friends/block", + json={"peer_fingerprint": "peerFP"}, + ) + assert resp.json()["state"] == "blocked" + + # Verify the peer link was revoked. + plink = await contacts.get_peer_link("hub:hogne") + assert plink is not None + assert plink.get("revoked_at") is not None + # Contact status should also be revoked. + contact = await contacts.get_contact("hub:hogne") + assert contact is not None + assert contact["status"] == "revoked" + finally: + await hub.close() diff --git a/tinyagentos/peer.py b/tinyagentos/peer.py index d48c67cd2..4cd5c6ebb 100644 --- a/tinyagentos/peer.py +++ b/tinyagentos/peer.py @@ -175,6 +175,84 @@ def _canonical_json(obj: dict) -> bytes: return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") +def send_handshake( + *, + to_username: str, + inbound_token: str, + endpoints: list[str], + signing_pubkey: str, + encryption_pubkey: str, +) -> dict: + """Build a handshake envelope addressed to a remote contact. + + The handshake envelope carries the inbound peer token (which the remote + instance should present as ``Authorization: Bearer `` when calling + our ``POST /api/peer/*`` routes), our advertised endpoints, and our public + keys so the remote side can pin them in its own contact row. + + Returns the envelope dict (not yet delivered). The caller is responsible + for delivering it to the peer's endpoints. + """ + from tinyagentos.hub import identity as _hub_identity + + local_ident = _hub_identity.public_identity() + from_username = resolve_local_identity_id() + if from_username is None: + raise RuntimeError("cannot send handshake: no local hub identity") + # Strip "hub:" prefix to get bare username + bare_from = from_username.split(":", 1)[1] if from_username.startswith("hub:") else from_username + + body = { + "inbound_token": inbound_token, + "endpoints": endpoints, + "signing_pubkey": signing_pubkey or local_ident.get("signing_pubkey", ""), + "encryption_pubkey": encryption_pubkey or local_ident.get("encryption_pubkey", ""), + } + return build_envelope( + from_username=bare_from, + to_username=to_username, + kind="handshake", + body=body, + ) + + +async def deliver_handshake( + envelope: dict, + peer_endpoints: list[str], + *, + http_client=None, +) -> bool: + """Deliver a handshake envelope to the peer's endpoints (best-effort). + + Tries each endpoint in order; stops on the first 2xx response. Returns + True if at least one endpoint accepted the envelope, False otherwise. + + ``http_client`` should be an ``httpx.AsyncClient``. If None, a temporary + client is created and torn down. + """ + import httpx + + own_client = http_client is None + if own_client: + http_client = httpx.AsyncClient(timeout=15.0) + + try: + for ep in peer_endpoints: + url = ep.rstrip("/") + "/api/peer/inbox" + try: + resp = await http_client.post( + url, json={"envelope": envelope}, + ) + if 200 <= resp.status_code < 300: + return True + except Exception: + continue + return False + finally: + if own_client: + await http_client.aclose() + + def resolve_local_identity_id(data_dir: str | Path | None = None) -> str | None: """Return this node's local hub identity ID (``"hub:"``), or None. diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 7b214ccee..9c551397c 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -25,6 +25,7 @@ """ from __future__ import annotations +import asyncio import json import logging from pathlib import Path @@ -35,7 +36,11 @@ from pydantic import BaseModel from tinyagentos.auth_context import CurrentUser, current_user +from tinyagentos.contacts_store import generate_peer_token from tinyagentos.hub import identity, posts, relationships, store as hub_store +from tinyagentos.peer import send_handshake as _send_handshake +from tinyagentos.peer import deliver_handshake as _deliver_handshake +from tinyagentos.peer import resolve_local_identity_id as _resolve_local_identity_id from tinyagentos.routes.account_proxy import _forward_to logger = logging.getLogger(__name__) @@ -95,6 +100,23 @@ async def _get_store(request: Request) -> hub_store.HubStore: return store +def _get_local_endpoints(request: Request) -> list[str]: + """Return this node's advertised endpoints for the handshake.""" + config_ep = None + try: + cfg = getattr(request.app.state, "config", None) + if cfg and hasattr(cfg, "server") and cfg.server: + host = cfg.server.get("host", "") + port = cfg.server.get("port", 6969) + if host and host != "0.0.0.0": + config_ep = f"https://{host}:{port}" if port == 443 else f"http://{host}:{port}" + except Exception: + pass + if config_ep: + return [config_ep] + return [] + + # --- slice 2: own profile --------------------------------------------------- @@ -299,8 +321,12 @@ async def accept_friend_request( whose presence / leave hints) and returns the parties' endpoints so the nodes complete the handshake directly. On a 2xx we record the mutual ``friend`` edge locally so this node's own presence gate and sync worker treat the peer as - an accepted edge (design: presence requires "an accepted edge"). The peer - fingerprint comes from the directory response when present, else the body. + an accepted edge (design: presence requires "an accepted edge"). + + After the hub edge is recorded, a contact row is created in the contacts store + (pinning the peer's Ed25519/X25519 pubkeys), an inbound peer token is minted, + a handshake envelope is delivered to the peer's endpoints (best-effort, async), + and the peer_link row is recorded. """ upstream = await _forward_to(request, "POST", f"/api/hub/requests/{rid}/accept") if upstream.status_code < 200 or upstream.status_code >= 300: @@ -323,6 +349,65 @@ async def accept_friend_request( await store.put_relationship( peer, relationships.REL_FRIEND, statement=statement ) + + # ------------------------------------------------------------------ + # A2: create contact row + peer link + handshake + # ------------------------------------------------------------------ + contacts = getattr(request.app.state, "contacts_store", None) + inbound_token = "" + if contacts is not None and peer: + # Extract identity info from the directory response. + username = resp.get("username") or peer + contact_id = f"hub:{username}" if not username.startswith("hub:") else username + display_name = resp.get("display_name") or username + ed25519_pub = resp.get("signing_pubkey") or resp.get("ed25519_pub") or "" + x25519_pub = resp.get("encryption_pubkey") or resp.get("x25519_pub") or "" + peer_endpoints = resp.get("endpoints") or [] + + try: + await contacts.add_contact( + contact_id=contact_id, + hub_username=username, + display_name=display_name, + ed25519_pub=ed25519_pub, + x25519_pub=x25519_pub, + status="active", + ) + except Exception: + logger.exception("friend-accept: contact row creation failed for %s", contact_id) + + # Mint the inbound peer token and record the peer link. + try: + inbound_token = generate_peer_token() + await contacts.establish_peer_link( + contact_id=contact_id, + inbound_token=inbound_token, + outbound_token="", # filled when the peer handshakes back + endpoints=list(peer_endpoints), + ) + except Exception: + logger.exception("friend-accept: peer link creation failed for %s", contact_id) + + # Fire-and-forget handshake delivery to the peer. + if peer_endpoints and ed25519_pub and x25519_pub: + try: + if _resolve_local_identity_id(request.app.state.data_dir): + envelope = _send_handshake( + to_username=username, + inbound_token=inbound_token, + endpoints=_get_local_endpoints(request), + signing_pubkey=ed25519_pub, + encryption_pubkey=x25519_pub, + ) + # Deliver the handshake in the background; failures are non-fatal. + asyncio.create_task( + _deliver_handshake(envelope, peer_endpoints) + ) + except Exception: + logger.exception( + "friend-accept: handshake delivery failed for %s", contact_id + ) + return {"state": "accepted", "peer": peer, "directory": resp} @@ -367,6 +452,9 @@ async def block_peer( so nothing about them is rendered or cached. It also asks the hub to sever the server-side accepted edge (no more presence visibility or hints either way); that directory call is best-effort and its result never blocks the local op. + + When a contact row exists for this peer, the peer link is revoked (cascade + per design section 8: contact block kills the peer link). """ store = await _get_store(request) # A social action implies this node has an identity (its fingerprint is the @@ -387,6 +475,20 @@ async def block_peer( ) except Exception as exc: # noqa: BLE001 logger.warning("hub block: directory edge revoke failed: %s", exc) + + # Cascade to contacts/peer_link: revoke the peer link if this peer is a contact. + contacts = getattr(request.app.state, "contacts_store", None) + if contacts is not None: + try: + # Resolve fingerprint → hub_username via the local hub_authors table. + author = await store.get_author(peer) + if author and author.get("username"): + contact_id = f"hub:{author['username']}" + await contacts.revoke_peer_link(contact_id) + logger.info("hub block: revoked peer link for %s", contact_id) + except Exception: + logger.exception("hub block: cascade to peer_link failed for %s", peer) + return {"state": "blocked", "peer": peer, "severed": severed}