feat(membership): room discovery — GET /members/{endpoint}/rooms + ListMyRooms
A peer invited to an encrypted room needs to find it: the control plane is
pull-based (no server push of invitations), so add a discovery endpoint that
lists every room an endpoint belongs to, with the room's metadata and the
endpoint's role.
- store.ListRoomsForEndpoint: JOIN members+rooms, ordered by room id, empty
slice (not error) for an endpoint in no rooms.
- membershipd: GET /members/{endpoint}/rooms returns {room_id, subject, epoch,
policy, role}[].
- client.ListMyRooms + RoomRef: a bot polls this to discover and then Join +
Subscribe rooms it was invited to.
Tests: store-level (owner in N rooms, member in one, unknown endpoint → []) and
client-level e2e through the embedded harness (B discovers a room A invited it
to, without prior knowledge of the room id; owner sees role=owner).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user