diff --git a/pkg/client/client.go b/pkg/client/client.go index 2bc4c35..f09cf35 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -231,8 +231,48 @@ type blobResp struct { Hash string `json:"hash"` } +type memberRoomJSON struct { + RoomID string `json:"room_id"` + Subject string `json:"subject"` + Epoch int `json:"epoch"` + Policy policyJSON `json:"policy"` + Role string `json:"role"` +} + // ---- room operations ------------------------------------------------------ +// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the +// unit of room discovery: a peer that was invited to a new room finds it here +// and can then Join (fetch the sealed key) and Subscribe. +type RoomRef struct { + RoomID string + Subject string + Epoch int + Policy room.Policy + Role string +} + +// ListMyRooms returns every room this peer is currently a member of. A peer +// polls this to discover rooms it has been invited to (the control plane is +// pull-based: there is no server push of invitations). +func (c *Client) ListMyRooms() ([]RoomRef, error) { + var resp []memberRoomJSON + if err := c.doJSON("GET", "/members/"+c.endpoint+"/rooms", nil, &resp); err != nil { + return nil, err + } + out := make([]RoomRef, 0, len(resp)) + for _, r := range resp { + out = append(out, RoomRef{ + RoomID: r.RoomID, + Subject: r.Subject, + Epoch: r.Epoch, + Policy: room.Policy{Encrypt: r.Policy.Encrypt, Persist: r.Policy.Persist, SignMsgs: r.Policy.SignMsgs}, + Role: r.Role, + }) + } + return out, nil +} + // newRoomKey returns 32 random bytes for a symmetric room key. func newRoomKey() ([]byte, error) { k := make([]byte, 32) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 1e07d61..8de36fb 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -402,6 +402,59 @@ func TestThreadedReplyAndReaction(t *testing.T) { } } +// TestListMyRoomsDiscovery verifies room discovery: an invited peer finds the +// room via ListMyRooms (without being told its id), and a peer in no rooms gets +// an empty list. This is what lets a bot discover rooms it was invited to. +func TestListMyRoomsDiscovery(t *testing.T) { + h := newHarness(t) + waitHealth(t, h.ctrlURL) + + a, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) + if err != nil { + t.Fatalf("connect A: %v", err) + } + defer a.Close() + b, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) + if err != nil { + t.Fatalf("connect B: %v", err) + } + defer b.Close() + + // B is in no rooms yet. + if rooms, err := b.ListMyRooms(); err != nil || len(rooms) != 0 { + t.Fatalf("B should start in no rooms, got %v err=%v", rooms, err) + } + + roomID, err := a.CreateRoom("room.discovery", room.ModeMatrix) + if err != nil { + t.Fatalf("A create room: %v", err) + } + if err := a.Invite(roomID, b.Endpoint()); err != nil { + t.Fatalf("A invite B: %v", err) + } + + // B discovers the room it was invited to, with its policy, without prior knowledge of the id. + rooms, err := b.ListMyRooms() + if err != nil { + t.Fatalf("B ListMyRooms: %v", err) + } + if len(rooms) != 1 || rooms[0].RoomID != roomID { + t.Fatalf("B should discover exactly room %s, got %+v", roomID, rooms) + } + if rooms[0].Subject != "room.discovery" || !rooms[0].Policy.Encrypt || rooms[0].Role != "member" { + t.Fatalf("discovered room metadata wrong: %+v", rooms[0]) + } + + // A sees the same room as its owner. + aRooms, err := a.ListMyRooms() + if err != nil { + t.Fatalf("A ListMyRooms: %v", err) + } + if len(aRooms) != 1 || aRooms[0].Role != "owner" { + t.Fatalf("A should own exactly one room, got %+v", aRooms) + } +} + // ---- test helpers --------------------------------------------------------- type collector struct { diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 330c151..9ee5df2 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -45,6 +45,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /rooms/{id}/invite", s.handleInvite) s.mux.HandleFunc("GET /rooms/{id}/key", s.handleGetKey) s.mux.HandleFunc("GET /rooms/{id}/members", s.handleListMembers) + s.mux.HandleFunc("GET /members/{endpoint}/rooms", s.handleListMemberRooms) s.mux.HandleFunc("POST /rooms/{id}/rekey", s.handleRekey) s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom) s.mux.HandleFunc("POST /blobs", s.handlePutBlob) @@ -101,6 +102,14 @@ type roomResp struct { Policy policyJSON `json:"policy"` } +type memberRoomJSON struct { + RoomID string `json:"room_id"` + Subject string `json:"subject"` + Epoch int `json:"epoch"` + Policy policyJSON `json:"policy"` + Role string `json:"role"` +} + type rekeyKey struct { Endpoint string `json:"endpoint"` SealedKey []byte `json:"sealed_key"` @@ -262,6 +271,30 @@ func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +func (s *Server) handleListMemberRooms(w http.ResponseWriter, r *http.Request) { + endpoint := r.PathValue("endpoint") + if endpoint == "" { + writeErr(w, http.StatusBadRequest, "endpoint required") + return + } + rooms, err := s.store.ListRoomsForEndpoint(endpoint) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]memberRoomJSON, 0, len(rooms)) + for _, rm := range rooms { + out = append(out, memberRoomJSON{ + RoomID: rm.RoomID, + Subject: rm.Subject, + Epoch: rm.Epoch, + Policy: policyJSON{Encrypt: rm.Encrypt, Persist: rm.Persist, SignMsgs: rm.SignMsgs}, + Role: rm.Role, + }) + } + writeJSON(w, http.StatusOK, out) +} + func (s *Server) handleGetRoom(w http.ResponseWriter, r *http.Request) { roomID := r.PathValue("id") info, err := s.store.GetRoom(roomID) diff --git a/pkg/membership/store.go b/pkg/membership/store.go index dfe34c9..6e4a7e2 100644 --- a/pkg/membership/store.go +++ b/pkg/membership/store.go @@ -219,6 +219,42 @@ func (s *Store) ListMembers(roomID string) ([]Member, error) { return out, rows.Err() } +// RoomMembership is a room an endpoint belongs to, with that endpoint's role. +// It is the per-endpoint view used for room discovery (a peer asking "which +// rooms am I in?") so a freshly-invited member can find and join its rooms. +type RoomMembership struct { + RoomInfo + Role string +} + +// ListRoomsForEndpoint returns every room the given endpoint is a member of, +// with the room's current metadata and the endpoint's role, ordered by room id. +// An endpoint that is in no rooms yields an empty slice (not an error). +func (s *Store) ListRoomsForEndpoint(endpoint string) ([]RoomMembership, error) { + rows, err := s.db.Query( + `SELECT r.room_id, r.subject, r.key_epoch, r.encrypt, r.persist, r.sign_msgs, r.owner_endpoint, m.role + FROM members m JOIN rooms r ON r.room_id = m.room_id + WHERE m.endpoint = ? ORDER BY r.room_id`, + endpoint, + ) + if err != nil { + return nil, fmt.Errorf("membership: list rooms for endpoint %q: %w", endpoint, err) + } + defer rows.Close() + + var out []RoomMembership + for rows.Next() { + var rm RoomMembership + var enc, per, sgn int + if err := rows.Scan(&rm.RoomID, &rm.Subject, &rm.Epoch, &enc, &per, &sgn, &rm.OwnerEndpoint, &rm.Role); err != nil { + return nil, fmt.Errorf("membership: scan room membership: %w", err) + } + rm.Encrypt, rm.Persist, rm.SignMsgs = enc != 0, per != 0, sgn != 0 + out = append(out, rm) + } + return out, rows.Err() +} + // GetSealedKey returns the sealed room key for an endpoint at a given epoch. // If epoch <= 0, the latest epoch for that endpoint is returned. func (s *Store) GetSealedKey(roomID, endpoint string, epoch int) (int, []byte, error) { diff --git a/pkg/membership/store_test.go b/pkg/membership/store_test.go index 1e88c9e..2d324cc 100644 --- a/pkg/membership/store_test.go +++ b/pkg/membership/store_test.go @@ -35,6 +35,58 @@ func TestMigrationsCreateSchema(t *testing.T) { } } +func TestListRoomsForEndpoint(t *testing.T) { + s := openTestStore(t) + + // Owner of two rooms; a member in only the first. + owner, member := "owner-ep", "member-ep" + mk := func(id, subj string) RoomInfo { + return RoomInfo{RoomID: id, Subject: subj, Encrypt: true, Persist: true, SignMsgs: true, OwnerEndpoint: owner} + } + if err := s.CreateRoom(mk("room-a", "room.a"), []byte("os"), []byte("ok"), []byte("k")); err != nil { + t.Fatalf("CreateRoom a: %v", err) + } + if err := s.CreateRoom(mk("room-b", "room.b"), []byte("os"), []byte("ok"), []byte("k")); err != nil { + t.Fatalf("CreateRoom b: %v", err) + } + if err := s.AddMember("room-a", Member{Endpoint: member, Role: "member", SignPub: []byte("s"), KexPub: []byte("k")}, 1, []byte("mk")); err != nil { + t.Fatalf("AddMember: %v", err) + } + + // Owner is in both rooms, as owner, ordered by room id. + ownerRooms, err := s.ListRoomsForEndpoint(owner) + if err != nil { + t.Fatalf("ListRoomsForEndpoint owner: %v", err) + } + if len(ownerRooms) != 2 { + t.Fatalf("owner: expected 2 rooms, got %d", len(ownerRooms)) + } + if ownerRooms[0].RoomID != "room-a" || ownerRooms[1].RoomID != "room-b" { + t.Fatalf("owner rooms not ordered: %+v", ownerRooms) + } + if ownerRooms[0].Role != "owner" || !ownerRooms[0].Encrypt || ownerRooms[0].Subject != "room.a" { + t.Fatalf("owner room metadata wrong: %+v", ownerRooms[0]) + } + + // Member is in exactly one room, as member. + memberRooms, err := s.ListRoomsForEndpoint(member) + if err != nil { + t.Fatalf("ListRoomsForEndpoint member: %v", err) + } + if len(memberRooms) != 1 || memberRooms[0].RoomID != "room-a" || memberRooms[0].Role != "member" { + t.Fatalf("member rooms wrong: %+v", memberRooms) + } + + // An unknown endpoint yields an empty slice, not an error. + none, err := s.ListRoomsForEndpoint("nobody") + if err != nil { + t.Fatalf("ListRoomsForEndpoint nobody: %v", err) + } + if len(none) != 0 { + t.Fatalf("expected no rooms for unknown endpoint, got %+v", none) + } +} + func TestRoomMemberKeyRoundTrip(t *testing.T) { s := openTestStore(t)