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}