Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

membership.gno

8.70 Kb · 316 lines
  1// Package membership is the Remix Lab membership block: an address-based
  2// member set with owner/reviewer/member roles, invitation or open joining,
  3// and bounded listing.
  4//
  5// A host realm constructs a *Set, keeps it in an unexported variable and
  6// authenticates every mutation before calling it. The Set enforces its own
  7// invariants (limits, last-owner protection, join rules) but not who may
  8// administer it: that is the host's policy decision. Never export the *Set
  9// or return it from a realm function; any holder of the pointer can call its
 10// mutating methods with the host's storage authority.
 11//
 12// Membership is address-based. One address is not one human.
 13package membership
 14
 15import (
 16	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/core/v1"
 17	"gno.land/p/nt/avl/v0"
 18)
 19
 20// JoinMode controls how a non-member becomes a member through Join.
 21type JoinMode int
 22
 23const (
 24	JoinInvitation JoinMode = iota // Join requires a pending invitation
 25	JoinOpen                       // anyone may Join
 26)
 27
 28func (m JoinMode) String() string {
 29	if m == JoinOpen {
 30		return "open"
 31	}
 32	return "invitation"
 33}
 34
 35// HardMaxMembers bounds MaxMembers regardless of configuration.
 36const HardMaxMembers = 1000
 37
 38// Config is the block configuration, fixed at construction.
 39type Config struct {
 40	JoinMode   JoinMode
 41	MaxMembers int // includes owners and reviewers
 42}
 43
 44// Validate checks configuration bounds.
 45func (c Config) Validate() error {
 46	if c.JoinMode != JoinInvitation && c.JoinMode != JoinOpen {
 47		return core.Errorf(core.ErrInvalidInput, "unknown join mode")
 48	}
 49	if c.MaxMembers < 1 || c.MaxMembers > HardMaxMembers {
 50		return core.Errorf(core.ErrInvalidInput, "maxMembers out of range")
 51	}
 52	return nil
 53}
 54
 55// Set is a membership set. The zero value is not usable; call New.
 56type Set struct {
 57	cfg     Config
 58	members *avl.Tree // address string -> core.Role
 59	invites *avl.Tree // address string -> core.Role (role granted on Join)
 60	owners  int
 61}
 62
 63// New creates a set whose first member is owner. It panics on invalid
 64// configuration or owner, since a host realm calls it from init.
 65func New(cfg Config, owner address) *Set {
 66	if err := cfg.Validate(); err != nil {
 67		panic(err.Error())
 68	}
 69	if !owner.IsValid() {
 70		panic(core.Errorf(core.ErrInvalidInput, "invalid owner address").Error())
 71	}
 72	s := &Set{cfg: cfg, members: avl.NewTree(), invites: avl.NewTree()}
 73	s.members.Set(owner.String(), core.RoleOwner)
 74	s.owners = 1
 75	return s
 76}
 77
 78// Config returns the set's configuration.
 79func (s *Set) Config() Config { return s.cfg }
 80
 81// RoleOf returns addr's role, RoleNone for non-members.
 82func (s *Set) RoleOf(addr address) core.Role {
 83	v := s.members.Get(addr.String())
 84	if v == nil {
 85		return core.RoleNone
 86	}
 87	return v.(core.Role)
 88}
 89
 90// IsMember reports whether addr holds any role.
 91func (s *Set) IsMember(addr address) bool {
 92	return s.members.Has(addr.String())
 93}
 94
 95// Size returns the number of members.
 96func (s *Set) Size() int { return s.members.Size() }
 97
 98// Owners returns the number of owners.
 99func (s *Set) Owners() int { return s.owners }
100
101// IsInvited reports whether addr holds a pending invitation.
102func (s *Set) IsInvited(addr address) bool {
103	return s.invites.Has(addr.String())
104}
105
106// PendingInvites returns the number of pending invitations.
107func (s *Set) PendingInvites() int { return s.invites.Size() }
108
109// InviteRole returns the role a pending invitation for addr grants.
110func (s *Set) InviteRole(addr address) (core.Role, bool) {
111	v := s.invites.Get(addr.String())
112	if v == nil {
113		return core.RoleNone, false
114	}
115	return v.(core.Role), true
116}
117
118// Invites returns up to limit pending invitations starting at offset,
119// ordered by address, with the role each one grants.
120func (s *Set) Invites(offset, limit int) []core.MemberView {
121	var out []core.MemberView
122	if offset < 0 || limit <= 0 {
123		return out
124	}
125	s.invites.IterateByOffset(offset, limit, func(k string, v any) bool {
126		out = append(out, core.MemberView{Address: address(k), Role: v.(core.Role)})
127		return false
128	})
129	return out
130}
131
132// Snapshot implements core.MembershipReader. A negative max or an invalid
133// minRole yields (nil, false).
134func (s *Set) Snapshot(minRole core.Role, max int) ([]address, bool) {
135	if max < 0 || !minRole.Valid() {
136		return nil, false
137	}
138	var out []address
139	ok := true
140	s.members.Iterate("", "", func(k string, v any) bool {
141		if v.(core.Role).AtLeast(minRole) {
142			if len(out) == max {
143				ok = false
144				return true
145			}
146			out = append(out, address(k))
147		}
148		return false
149	})
150	if !ok {
151		return nil, false
152	}
153	return out, true
154}
155
156// List returns up to limit members starting at offset, ordered by address.
157func (s *Set) List(offset, limit int) []core.MemberView {
158	var out []core.MemberView
159	if offset < 0 || limit <= 0 {
160		return out
161	}
162	s.members.IterateByOffset(offset, limit, func(k string, v any) bool {
163		out = append(out, core.MemberView{Address: address(k), Role: v.(core.Role)})
164		return false
165	})
166	return out
167}
168
169// Invite records a pending invitation granting role on Join. Only valid in
170// invitation mode. Pending invitations count against MaxMembers so that
171// accepting every invitation can never exceed the limit.
172func (s *Set) Invite(addr address, role core.Role) error {
173	if s.cfg.JoinMode != JoinInvitation {
174		return core.Errorf(core.ErrInvalidState, "set uses open joining")
175	}
176	if err := checkGrantable(addr, role); err != nil {
177		return err
178	}
179	if s.IsMember(addr) {
180		return core.Errorf(core.ErrConflict, "already a member")
181	}
182	if s.IsInvited(addr) {
183		return core.Errorf(core.ErrConflict, "already invited")
184	}
185	if err := s.checkCapacity(); err != nil {
186		return err
187	}
188	s.invites.Set(addr.String(), role)
189	return nil
190}
191
192// RevokeInvite removes a pending invitation.
193func (s *Set) RevokeInvite(addr address) error {
194	if _, removed := s.invites.Remove(addr.String()); !removed {
195		return core.Errorf(core.ErrNotFound, "no pending invitation")
196	}
197	return nil
198}
199
200// Join adds actor as a member. actor must be the authenticated caller.
201func (s *Set) Join(actor address) error {
202	if s.IsMember(actor) {
203		return core.Errorf(core.ErrConflict, "already a member")
204	}
205	role := core.RoleMember
206	if s.cfg.JoinMode == JoinInvitation {
207		v, removed := s.invites.Remove(actor.String())
208		if !removed {
209			return core.Errorf(core.ErrUnauthorized, "invitation required")
210		}
211		role = v.(core.Role)
212	} else {
213		if !actor.IsValid() {
214			return core.Errorf(core.ErrInvalidInput, "invalid address")
215		}
216		if err := s.checkCapacity(); err != nil {
217			return err
218		}
219	}
220	s.members.Set(actor.String(), role)
221	if role == core.RoleOwner {
222		s.owners++
223	}
224	return nil
225}
226
227// Leave removes actor. actor must be the authenticated caller. The last
228// owner cannot leave.
229func (s *Set) Leave(actor address) error {
230	return s.remove(actor)
231}
232
233// Add directly adds addr with role. The host must have authorized the actor.
234func (s *Set) Add(addr address, role core.Role) error {
235	if err := checkGrantable(addr, role); err != nil {
236		return err
237	}
238	if s.IsMember(addr) {
239		return core.Errorf(core.ErrConflict, "already a member")
240	}
241	// An invitation for the same address is superseded by the direct add.
242	_, hadInvite := s.invites.Remove(addr.String())
243	if !hadInvite {
244		if err := s.checkCapacity(); err != nil {
245			return err
246		}
247	}
248	s.members.Set(addr.String(), role)
249	if role == core.RoleOwner {
250		s.owners++
251	}
252	return nil
253}
254
255// Remove removes addr. The host must have authorized the actor. The last
256// owner cannot be removed.
257func (s *Set) Remove(addr address) error {
258	return s.remove(addr)
259}
260
261// SetRole changes an existing member's role. Demoting the last owner fails.
262func (s *Set) SetRole(addr address, role core.Role) error {
263	if err := checkGrantable(addr, role); err != nil {
264		return err
265	}
266	cur := s.RoleOf(addr)
267	if cur == core.RoleNone {
268		return core.Errorf(core.ErrNotFound, "not a member")
269	}
270	if cur == role {
271		return nil
272	}
273	if cur == core.RoleOwner && s.owners == 1 {
274		return core.Errorf(core.ErrInvalidState, "cannot demote the last owner")
275	}
276	if cur == core.RoleOwner {
277		s.owners--
278	}
279	if role == core.RoleOwner {
280		s.owners++
281	}
282	s.members.Set(addr.String(), role)
283	return nil
284}
285
286func (s *Set) remove(addr address) error {
287	cur := s.RoleOf(addr)
288	if cur == core.RoleNone {
289		return core.Errorf(core.ErrNotFound, "not a member")
290	}
291	if cur == core.RoleOwner {
292		if s.owners == 1 {
293			return core.Errorf(core.ErrInvalidState, "cannot remove the last owner")
294		}
295		s.owners--
296	}
297	s.members.Remove(addr.String())
298	return nil
299}
300
301func (s *Set) checkCapacity() error {
302	if s.members.Size()+s.invites.Size() >= s.cfg.MaxMembers {
303		return core.Errorf(core.ErrLimitExceeded, "membership is full")
304	}
305	return nil
306}
307
308func checkGrantable(addr address, role core.Role) error {
309	if !addr.IsValid() {
310		return core.Errorf(core.ErrInvalidInput, "invalid address")
311	}
312	if role == core.RoleNone || !role.Valid() {
313		return core.Errorf(core.ErrInvalidInput, "invalid role")
314	}
315	return nil
316}