diff --git a/examples/gno.land/r/sys/teams/cmd.gno b/examples/gno.land/r/sys/teams/cmd.gno new file mode 100644 index 00000000000..03b86b2b8e8 --- /dev/null +++ b/examples/gno.land/r/sys/teams/cmd.gno @@ -0,0 +1,119 @@ +package teams + +import ( + "errors" + "std" + "strings" +) + +// XXX: Improve errors +var ErrAlreadyExist = errors.New("already exists") +var ErrDoesNotExist = errors.New("does not exist") + +// Cmd represents a command that can be executed on a team. +type Cmd interface { + Name() string +} + +type Cmds []Cmd + +func (cmds Cmds) Name() string { + var str strings.Builder + str.WriteRune('[') + for i, cmd := range cmds { + if i > 0 { + str.WriteRune(',') + } + + str.WriteString(cmd.Name()) + } + str.WriteRune(']') + return str.String() +} + +// AddMemberCmd represents a command to add a member to the team. +type AddMemberCmd struct { + Member std.Address +} + +func (cmd AddMemberCmd) Name() string { return "AddMember" } + +// AddMemberTask creates a task to add a member to the team. +func AddMemberTask(member std.Address) Task { + return CreateTask(func(t *Team) Cmd { + // Cannot add team address as a member + if t.IsTeamAddress(member) { + panic("cannot add team address as a member") + } + + if t.members.Has(member.String()) { + panic(ErrAlreadyExist) + } + t.members.Set(member.String(), struct{}{}) + return nil + }) +} + +// RemoveMemberCmd represents a command to remove a member from the team. +type RemoveMemberCmd struct { + Member std.Address +} + +func (cmd RemoveMemberCmd) Name() string { return "RemoveMember" } + +// RemoveMemberTask creates a task to remove a member from the team. +func RemoveMemberTask(member std.Address) Task { + return CreateTask(func(t *Team) Cmd { + if !t.members.Has(member.String()) { + panic(ErrDoesNotExist) + } + t.members.Remove(member.String()) + return nil + }) +} + +// The command bellow should be use with precaution + +// UpdateAccessControllerCmd represents a command to update the team's access controller. +type UpdateAccessControllerCmd struct { + AccessController +} + +func (cmd UpdateAccessControllerCmd) Name() string { return "UpdateAccessController" } + +// UpdateAccessControllerTask creates a task to update the team's access controller. +func UpdateAccessControllerTask(ac AccessController) Task { + return CreateTask(func(t *Team) Cmd { + t.AccessController = ac + return nil + }) +} + +// UpdateLifecycleCmd represents a command to update the team's access controller. +type UpdateLifecycleCmd struct { + Lifecycle +} + +func (cmd UpdateLifecycleCmd) Name() string { return "UpdateLifecycle" } + +// UpdateLifecycleTask creates a task to update the team's access controller. +func UpdateLifecycleTask(ac Lifecycle) Task { + return CreateTask(func(t *Team) Cmd { + t.Lifecycle = ac + return nil + }) +} + +// BurnTeamAddressCmd represents a command to burn the team's address. +type BurnTeamAddressCmd struct{} + +func (cmd BurnTeamAddressCmd) Name() string { return "BurnTeamAddress" } + +var BurnTeamAddressTask = CreateTaskCmd(BurnTeamAddressCmd{}) + +// AddPackageCmd represents a command to add a package to the team. +type AddPackageCmd struct { + Path string +} + +func (cmd AddPackageCmd) Name() string { return "AddPackage" } diff --git a/examples/gno.land/r/sys/teams/gno.mod b/examples/gno.land/r/sys/teams/gno.mod new file mode 100644 index 00000000000..4afbd1a0fe9 --- /dev/null +++ b/examples/gno.land/r/sys/teams/gno.mod @@ -0,0 +1 @@ +module gno.land/r/sys/teams \ No newline at end of file diff --git a/examples/gno.land/r/sys/teams/task.gno b/examples/gno.land/r/sys/teams/task.gno new file mode 100644 index 00000000000..1ea1020f798 --- /dev/null +++ b/examples/gno.land/r/sys/teams/task.gno @@ -0,0 +1,85 @@ +package teams + +// TaskFunc defines a function type that takes a Team and returns a Cmd. +// It represents the executable logic that can be performed on a team. +type TaskFunc func(t *Team) Cmd + +// Task represents a unit of work that can be executed to apply a command. +// Tasks encapsulate the execution logic of commands, ensuring that operations +// are performed with the correct permissions and in a specified order. +// +// Tasks are used to execute commands that modify the team's state, ensuring +// that these modifications adhere to the permissions set by the access control +// mechanisms and are executed in a controlled sequence. +type Task interface { + call(t *Team) Cmd +} + +type task struct { + actionFunc TaskFunc +} + +func (a task) call(t *Team) Cmd { + return a.actionFunc(t) +} + +// CreateTaskCmd creates a Task from one or more commands. +func CreateTaskCmd(cmd ...Cmd) Task { + switch len(cmd) { + case 0: + return nil + case 1: + return CreateTask(func(_ *Team) Cmd { + return cmd[0] + }) + default: + // Handle multiple commands + } + + fns := make([]TaskFunc, len(cmd)) + for i, m := range cmd { + fns[i] = func(_ *Team) Cmd { + return m + } + } + return CreateTask(fns...) +} + +// CreateTask creates a Task from one or more TaskFuncs. +func CreateTask(fn ...TaskFunc) Task { + switch len(fn) { + case 0: + return nil + case 1: + return &task{actionFunc: fn[0]} + default: + // Handle multiple functions + } + + actions := make([]Task, len(fn)) + for i, f := range fn { + actions[i] = &task{actionFunc: f} + } + return ChainTasks(actions...) +} + +// ChainTasks creates a single Task that executes a series of tasks in sequence. +// It combines multiple tasks into one. +func ChainTasks(actions ...Task) Task { + switch len(actions) { + case 0: + return nil + case 1: + return actions[0] + default: + // Handle chaining of multiple tasks + } + + return CreateTask(func(t *Team) Cmd { + cmds := make([]Cmd, len(actions)) + for i, action := range actions { + cmds[i] = action.call(t) + } + return Cmds(cmds) + }) +} diff --git a/examples/gno.land/r/sys/teams/team.gno b/examples/gno.land/r/sys/teams/team.gno new file mode 100644 index 00000000000..17d7ee79b72 --- /dev/null +++ b/examples/gno.land/r/sys/teams/team.gno @@ -0,0 +1,248 @@ +package teams + +import ( + "std" + + "gno.land/p/demo/avl" +) + +type Team struct { + AccessController + Lifecycle + + address std.Address // Origin address of the team + members avl.Tree // std.Address -> void + burned bool + isUpdating bool // internal flag for update status +} + +// Run executes a series of commands on the team, ensuring that only authorized +// members can perform these operations. It translates commands into tasks and +// executes them to update the team's state. +// +// Flow: +// 1. **Caller Verification**: Checks if the caller is a team member or the team address. +// 2. **Command Translation**: Translates commands into tasks for execution. +// 3. **Task Execution**: Executes tasks in sequence, updating the team's state. +func (team *Team) Run(cmds ...Cmd) { + if !team.IsRegistered() { + panic("team is not registered") + } + + caller := std.GetOrigCaller() + if !team.IsTeamAddress(caller) && !team.HasMember(caller) { + panic("only members or team address can perform commands on the team") + } + + // Get tasks for the given cmds + tasks := team.getTasksForCmds(caller, cmds...) + // Perform tasks + team.performTasks(caller, tasks...) +} + +// CanRun checks if a member can run a specific command. +func (team *Team) CanRun(member std.Address, cmd Cmd) bool { + if !team.IsRegistered() { // team hasn't been registered + return false + } + + isTeamAddress := team.IsTeamAddress(member) + isMember := team.HasMember(member) + + if !isMember && !isTeamAddress { + return false + } + + // TeamAddress has all the rights until it has been burned. + if !team.burned && isTeamAddress { + return true + } + + // If an AccessController is set, delegate the check. + if team.AccessController != nil { + return team.AccessController.CanRun(member, cmd) + } + + // Fallback on Default Permission + switch cmd.(type) { + case AddMemberCmd, AddPackageCmd: + return true // any member can do it + default: + return false + } +} + +// Init initializes the team lifecycle. +func (team *Team) Init() Task { + if team.Lifecycle != nil { + return team.Lifecycle.Init() + } + return nil +} + +// ApplyUpdate applies a command update to the team. +func (team *Team) ApplyUpdate(cmd Cmd) Task { + if team.Lifecycle != nil { + return team.Lifecycle.ApplyUpdate(cmd) + } + return ApplyDefault(cmd) +} + +// ApplyDefault handles default command updates. +func ApplyDefault(cmd Cmd) Task { + switch typ := cmd.(type) { + case assertDefaultUpdateCmd: + typ.assert = true + return CreateTaskCmd(typ) // send it back + case AddMemberCmd: + return AddMemberTask(typ.Member) + case RemoveMemberCmd: + return RemoveMemberTask(typ.Member) + case AddPackageCmd: // XXX: Consider implementation + return nil + case UpdateAccessControllerCmd: + return CreateTask(func(t *Team) Cmd { + if t.AccessController != nil { + panic("AccessController already set") + } + t.AccessController = typ.AccessController + return nil + }) + case UpdateLifecycleCmd: + return CreateTask(func(t *Team) Cmd { + if t.Lifecycle != nil { + panic("lifecycle already set") + } + t.Lifecycle = typ.Lifecycle + return nil + }) + default: + panic("command not supported: [" + cmd.Name() + "]") + } +} + +// HasMember checks if a given address is a member of the team. +func (team *Team) HasMember(member std.Address) bool { + return team.members.Has(member.String()) +} + +// IsTeamAddress checks if a given address is the team's address. +func (team *Team) IsTeamAddress(teamAddr std.Address) bool { + return teamAddr == team.address +} + +// IsRegistered checks if the team is registered. +func (team *Team) IsRegistered() bool { + return team.address != "" +} + +// Address returns the team's address. +func (team *Team) Address() std.Address { + return team.address +} + +// CanAddPackage checks if a member can add a package. +func (team *Team) CanAddPackage(member std.Address) bool { + return team.CanRun(member, AddPackageCmd{}) +} + +// BurnTeamAddress prevents the team address from managing the team, leaving it to the members. +// WARNING: This is irreversible. +func (team *Team) BurnTeamAddress() { + team.Run(BurnTeamAddressCmd{}) +} + +// AddMember adds a member to the team. +func (team *Team) AddMember(member std.Address) { + team.Run(AddMemberCmd{Member: member}) +} + +// CanAddMember checks if a member can add another member. +func (team *Team) CanAddMember(member, target std.Address) bool { + return team.CanRun(member, AddMemberCmd{Member: member}) +} + +// RemoveMember removes a member from the team. +func (team *Team) RemoveMember(member std.Address) { + team.Run(RemoveMemberCmd{Member: member}) +} + +// CanRemoveMember checks if a member can remove another member. +func (team *Team) CanRemoveMember(member, target std.Address) bool { + return team.CanRun(member, RemoveMemberCmd{Member: target}) +} + +// performTasks executes a list of tasks. +func (team *Team) performTasks(caller std.Address, tasks ...Task) { + if team.isUpdating { + panic("cannot perform task while updating, ensure returning the task instead") + } + + var task Task + for len(tasks) > 0 { + task, tasks = tasks[0], tasks[1:] // Shift task + if task == nil { + continue // Skip empty task + } + + if cmd := task.call(team); cmd != nil { + nextTasks := team.getTasksForCmds(caller, cmd) + tasks = append(nextTasks, tasks...) + } + } +} + +// getTasksForCmds translates commands into tasks. +func (team *Team) getTasksForCmds(caller std.Address, cmds ...Cmd) []Task { + team.isUpdating = true + defer func() { team.isUpdating = false }() + + tasks := make([]Task, 0, len(cmds)) + for _, cmd := range cmds { + if cmd == nil { + continue // Skip empty cmd + } + + switch typ := cmd.(type) { + case Cmds: + subTasks := team.getTasksForCmds(caller, typ...) + tasks = append(tasks, subTasks...) + case BurnTeamAddressCmd: + if team.burned { + panic("already burned") + } + + if !team.IsTeamAddress(caller) { + panic("only team address can burn") + } + + tasks = append(tasks, CreateTask(func(t *Team) Cmd { + team.burned = true + return nil + })) + + // XXX: Consider if fallthrough is needed + default: + if !team.CanRun(caller, cmd) { + panic("unauthorized command for caller: [" + cmd.Name() + "]") + } + + tasks = append(tasks, team.ApplyUpdate(cmd)) + } + } + return tasks +} + +type assertDefaultUpdateCmd struct{ assert bool } + +func (assertDefaultUpdateCmd) Name() string { return "assertDefaultUpdateCmd" } + +// assertDefaultUpdate ensures the team implementation handles team update fallback. +func assertDefaultUpdate(team *Team) { + task := team.ApplyUpdate(assertDefaultUpdateCmd{}) + cmd := task.call(team) + if assertCmd, ok := cmd.(assertDefaultUpdateCmd); ok && assertCmd.assert { + return + } + panic("ensure that team implementation handles team update fallback") +} diff --git a/examples/gno.land/r/sys/teams/teams.gno b/examples/gno.land/r/sys/teams/teams.gno new file mode 100644 index 00000000000..ec44574b0fb --- /dev/null +++ b/examples/gno.land/r/sys/teams/teams.gno @@ -0,0 +1,128 @@ +package teams + +import ( + "regexp" + "std" + + "gno.land/p/demo/avl" +) + +// AccessController defines the interface for controlling access to team commands. +// Implementations should define the logic to determine if a member can run a specific command. +type AccessController interface { + // CanRun determines if a member is authorized to execute a specific command. + CanRun(member std.Address, cmd Cmd) bool +} + +// Lifecycle defines the interface for managing the lifecycle of a team. +// It provides methods for initializing a team and applying updates through commands. +// Implementations of this interface should define how a team is set up initially +// and how it responds to changes over time. +type Lifecycle interface { + // Init initializes the team with a series of commands. + // It returns a Task that encapsulates the initialization logic. + // + // The Init method is called during the creation of a team to perform + // initial setup actions such as adding founding members or configuring + // initial settings. + // + // Example: + // func (m *MyTeam) Init() teams.Task { + // return teams.CreateTaskCmd( + // AddMemberTask(m.Owner()), // Add Owner as Member + // SetLevelTask(m.Owner(), Level4), // Set Owner's level to Level4 + // BurnTeamAddressTask, // Make team address unusable + // ) + // } + Init() Task + + // ApplyUpdate applies an update to the team based on a given command. + // It returns a Task that represents the execution of this update. + // + // The ApplyUpdate method is used to modify the team's state in response + // to commands such as adding or removing members, or changing member roles. + // Before executing a command, ApplyUpdate ensures that the member + // issuing the command has the necessary permissions, maintaining the + // security and integrity of team operations. + // + // ApplyUpdate should always fallback on `teams.ApplyDefault`. + // + // Example: + // func (m *MyTeam) ApplyUpdate(cmd teams.Cmd) teams.Task { + // switch typ := cmd.(type) { + // case SetLevelCmd: + // return teams.CreateTask(func(_ *teams.Team) teams.Cmd { + // m.levels.Set(typ.Member.String(), typ.Level) + // return nil + // }) + // } + // return team.ApplyDefault(cmd) + // } + ApplyUpdate(cmd Cmd) Task +} + +// ITeam combines the AccessController and Lifecycle interfaces to define a complete +// team interface. It ensures that a team has both access control and lifecycle +// management capabilities. +type ITeam interface { + AccessController + Lifecycle +} + +var teams avl.Tree // std.Address -> *Team + +// reHomeRealm validate thehome realm path format `xxx.xx/r//home`. +// XXX: Use somthing simpler +var reHomeRealm = regexp.MustCompile(`^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z]{2,}/r/[a-z0-9_]+/home$`) + +// Register creates and registers a new team in the `r/sys/teams` realm, allowing a registered +// user to transform into a team by publishing a contract that registers members in a registry. +// +// The `Register` function verifies that the caller is a valid home path (`r//home`), +// using `` as the team name. +func Register(iteam ITeam) *Team { + caller := std.GetOrigCaller() + realm := std.PrevRealm() + + // Check if caller is not already registered as a team + if teams.Has(caller.String()) { + panic("team already registered: " + caller.String()) + } + + // Check if origin caller is a home path + if !reHomeRealm.MatchString(realm.PkgPath()) { + panic("cannot register a team outside a home realm") + } + + // Initialize the team + team := &Team{ + address: caller, + Lifecycle: iteam, + AccessController: iteam, + } + // Assert that team implementation correctly uses fallback + // XXX: do we want this? + // It asserts that caller has a minimal implementation of Update + assertDefaultUpdate(team) + + if initTask := team.Init(); initTask != nil { + team.performTasks(caller, initTask) + } + + // All set, register the team + teams.Set(caller.String(), team) + return team +} + +// Get retrieves a registered team by address. +func Get(teamAddr std.Address) *Team { + if t, ok := teams.Get(teamAddr.String()); ok { + return t.(*Team) + } + return nil +} + +// IsRegister checks if a team is already registered by address. +func IsRegister(teamAddr std.Address) bool { + return teams.Has(teamAddr.String()) +} diff --git a/examples/gno.land/r/sys/teams/teams_ownable.gno b/examples/gno.land/r/sys/teams/teams_ownable.gno new file mode 100644 index 00000000000..9d42a9d18da --- /dev/null +++ b/examples/gno.land/r/sys/teams/teams_ownable.gno @@ -0,0 +1,59 @@ +package teams + +import ( + "std" + + "gno.land/p/demo/ownable" +) + +type OwnableTeam struct { + *OwnableAccessController +} + +func NewOwnableTeam(owner *ownable.Ownable) ITeam { + return &OwnableTeam{ + OwnableAccessController: NewOwnableAccessController(owner), + } +} + +func (o *OwnableTeam) Init() Task { + return ChainTasks( + AddMemberTask(o.Owner()), + // Burn team address, so only owner can control the team + BurnTeamAddressTask, + ) +} + +func (o *OwnableTeam) ApplyUpdate(cmd Cmd) Task { + return ApplyDefault(cmd) +} + +type OwnableAccessController struct { + *ownable.Ownable + + EnableAddMember bool + EnableRemoveMember bool + EnableAddPackage bool +} + +func NewOwnableAccessController(ownable *ownable.Ownable) *OwnableAccessController { + return &OwnableAccessController{Ownable: ownable} +} + +func (o *OwnableAccessController) CanRun(member std.Address, cmd Cmd) bool { + + if o.Ownable.Owner() == member { // All mighty owner + return true + } + + switch cmd.(type) { + case AddMemberCmd: + return o.EnableAddMember + case RemoveMemberCmd: + return o.EnableRemoveMember + case AddPackageCmd: + return o.EnableAddPackage + } + + return false +} diff --git a/examples/gno.land/r/sys/teams/teams_test.gno b/examples/gno.land/r/sys/teams/teams_test.gno new file mode 100644 index 00000000000..e403cc9bd87 --- /dev/null +++ b/examples/gno.land/r/sys/teams/teams_test.gno @@ -0,0 +1,3 @@ +package teams + +// XXX: TODO diff --git a/examples/gno.land/r/sys/teams/z_1_filetest.gno b/examples/gno.land/r/sys/teams/z_1_filetest.gno new file mode 100644 index 00000000000..0a7e75b2c10 --- /dev/null +++ b/examples/gno.land/r/sys/teams/z_1_filetest.gno @@ -0,0 +1,42 @@ +// PKGPATH: gno.land/r/myteam/home + +// This the most minimal usage of team +package home + +import ( + "gno.land/p/demo/testutils" + "gno.land/r/sys/teams" +) + +var myteam *teams.Team + +// var myteam *Team +func init() { + myteam = teams.Register(nil) +} + +func main() { + alice := testutils.TestAddress("alice") + teamAddress := myteam.Address() + + // Setup team user address + println("team address is team address:", myteam.IsTeamAddress(teamAddress)) + println("team address is not member:", !myteam.HasMember(teamAddress)) + println("team address can add package:", myteam.CanAddPackage(teamAddress)) + println("team address can add member alice:", myteam.CanAddMember(teamAddress, alice)) + println("alice is not member:", !myteam.HasMember(alice)) + + println(" -> adding alice as member") + myteam.AddMember(alice) + + println("alice is now a member:", myteam.HasMember(alice)) +} + +// Output: +// team address is team address: true +// team address is not member: true +// team address can add package: true +// team address can add member alice: true +// alice is not member: true +// -> adding alice as member +// alice is now a member: true diff --git a/examples/gno.land/r/sys/teams/z_9_filetest.gno b/examples/gno.land/r/sys/teams/z_9_filetest.gno new file mode 100644 index 00000000000..a85353177d5 --- /dev/null +++ b/examples/gno.land/r/sys/teams/z_9_filetest.gno @@ -0,0 +1,211 @@ +// PKGPATH: gno.land/r/myteam/home + +// This is an example of a more advanced usage of team +package home + +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/testutils" + "gno.land/r/sys/teams" +) + +// var myteam *Team + +type Level int + +const ( + LevelUnknown Level = iota // lowest + Level1 + Level2 + Level3 + Level4 +) + +type MyTeam struct { + *teams.Team + address std.Address + levels avl.Tree // std.Address -> Level +} + +func (m *MyTeam) Init() teams.Task { + return nil +} + +func (m *MyTeam) CanRun(member std.Address, cmd teams.Cmd) bool { + level := m.GetLevel(member) + shouldBeLevelMinimum := func(target Level) bool { + return level >= target + } + + // Team action + switch typ := cmd.(type) { + case SetLevelCmd: + // Can only set level on inferior level + return level > typ.Level + case teams.RemoveMemberCmd: + return level >= Level3 + case teams.AddMemberCmd: + return level >= Level2 + case teams.AddPackageCmd: + return level >= Level1 + } + + return false +} + +type SetLevelCmd struct { + Member std.Address + Level +} + +func (SetLevelCmd) Name() string { return "SetLevel" } + +func (m *MyTeam) ApplyUpdate(cmd teams.Cmd) teams.Task { + switch typ := cmd.(type) { + case SetLevelCmd: + return teams.CreateTask(func(_ *teams.Team) teams.Cmd { + mkey := typ.Member.String() + m.levels.Set(mkey, typ.Level) + return nil + }) + case teams.AddMemberCmd: + return teams.ChainTasks( + // Add a new member + teams.AddMemberTask(typ.Member), + // Promote it to level 1 + teams.CreateTaskCmd(SetLevelCmd{ + Member: typ.Member, + Level: Level1, + }), + ) + case teams.RemoveMemberCmd: + return teams.ChainTasks( + // Add a new member + teams.RemoveMemberTask(typ.Member), + // Promote it to level 1 + teams.CreateTaskCmd(SetLevelCmd{ + Member: typ.Member, + Level: LevelUnknown, + }), + ) + + } + + return teams.ApplyDefault(cmd) +} + +func (m *MyTeam) GetLevel(member std.Address) Level { + if level, ok := m.levels.Get(member.String()); ok { + return level.(Level) + } + return LevelUnknown +} + +func (m *MyTeam) SetLevel(member std.Address, level Level) { + m.Team.Run(SetLevelCmd{ + Member: member, + Level: level, + }) +} + +var myteam MyTeam + +func init() { + // inherit all methods from Team + myteam.Team = teams.Register(&myteam) +} + +func main() { + println("* registered myteam") + myteamUser := myteam.Address() + + // Setup team user address + println("myteamUser is team address:", myteam.IsTeamAddress(myteamUser)) + println("myteamUser is not member:", !myteam.HasMember(myteamUser)) + println("myteamUser has not level:", myteam.GetLevel(myteamUser) == LevelUnknown) + println("myteamUser can add package:", myteam.CanAddPackage(myteamUser)) + + // Setup test users + alice := testutils.TestAddress("alice") + bob := testutils.TestAddress("bob") + + println("alice cannot add a package:", !myteam.CanAddPackage(alice)) + + // Register alice to the team + println(" -> adding alice as a member") + myteam.AddMember(alice) + + println("alice is member:", myteam.HasMember(alice)) + println("alice is level_1:", myteam.GetLevel(alice) == Level1) + println("alice can add package:", myteam.CanAddPackage(alice)) + println("bob cannot add package:", !myteam.CanAddPackage(bob)) + // Alice should not be able to add a member on level1 + println("as level_1, alice cannot add bob as member:", !myteam.CanAddMember(alice, bob)) + + // Update alice to Level4 + println(" -> setting alice to level 4") + myteam.SetLevel(alice, Level4) + + println("alice is level_4:", myteam.GetLevel(alice) == Level4) + println("alice can add bob as member:", myteam.CanAddMember(alice, bob)) + + // Burn team address + println(" -> burn team address") + myteam.BurnTeamAddress() + + println("myteamUser is not member:", !myteam.HasMember(myteamUser)) + println("myteamUser is level_Unknown:", myteam.GetLevel(myteamUser) == LevelUnknown) + println("myteamUser cannot add package:", !myteam.CanAddPackage(myteamUser)) + + println("alice is still level_4:", myteam.GetLevel(alice) == Level4) + println("alice can still add bob as member:", myteam.CanAddMember(alice, bob)) + + // Set caller to alice + println(" -> setting alice as origin caller") + std.TestSetOrigCaller(alice) + + // alice add member bob + println(" -> alice add bob as member") + myteam.AddMember(bob) + + println("bob is member:", myteam.HasMember(bob)) + println("bob is level_1:", myteam.GetLevel(bob) == Level1) + println("bob can add package:", myteam.CanAddPackage(bob)) + + println(" -> removing bob") + myteam.RemoveMember(bob) + println("bob is not member:", !myteam.HasMember(bob)) + +} + +// Output: +// * registered myteam +// myteamUser is team address: true +// myteamUser is not member: true +// myteamUser has not level: true +// myteamUser can add package: true +// alice cannot add a package: true +// -> adding alice as a member +// alice is member: true +// alice is level_1: true +// alice can add package: true +// bob cannot add package: true +// as level_1, alice cannot add bob as member: true +// -> setting alice to level 4 +// alice is level_4: true +// alice can add bob as member: true +// -> burn team address +// myteamUser is not member: true +// myteamUser is level_Unknown: true +// myteamUser cannot add package: true +// alice is still level_4: true +// alice can still add bob as member: true +// -> setting alice as origin caller +// -> alice add bob as member +// bob is member: true +// bob is level_1: true +// bob can add package: true +// -> removing bob +// bob is not member: true diff --git a/examples/gno.land/r/sys/users/verify.gno b/examples/gno.land/r/sys/users/verify.gno index 71869fda1a1..ac156a8781e 100644 --- a/examples/gno.land/r/sys/users/verify.gno +++ b/examples/gno.land/r/sys/users/verify.gno @@ -5,6 +5,7 @@ import ( "gno.land/p/demo/ownable" "gno.land/r/demo/users" + "gno.land/r/sys/teams" ) const admin = "g1manfred47kzduec920z88wfr64ylksmdcedlf5" // @moul @@ -38,6 +39,12 @@ func VerifyNameByUser(enable bool, address std.Address, name string) bool { } if user := users.GetUserByName(name); user != nil { + // Check if team exist first + // XXX: for now a team is still user + if team := teams.Get(user.Address); team != nil { + return team.CanAddPackage(address) + } + return user.Address == address } diff --git a/gno.land/pkg/integration/testdata/register_team.txtar b/gno.land/pkg/integration/testdata/register_team.txtar new file mode 100644 index 00000000000..d1531369f7a --- /dev/null +++ b/gno.land/pkg/integration/testdata/register_team.txtar @@ -0,0 +1,98 @@ +# this testscript reproduce team register flow on https://github.com/gnolang/gno/issues/2195#issuecomment-2364056001 + +loadpkg gno.land/r/sys/teams +loadpkg gno.land/r/sys/users +loadpkg gno.land/r/demo/users + +adduser admin + +adduser alice +adduser bob + +patchpkg "g1manfred47kzduec920z88wfr64ylksmdcedlf5" $admin_user_addr # use our custom admin + +gnoland start + +# enable sys/users +gnokey maketx call -pkgpath gno.land/r/sys/users -func AdminEnable -gas-fee 100000ugnot -gas-wanted 1000000 -broadcast -chainid tendermint_test admin +stdout 'OK!' + +# Try to add a pkg an with unregistered user +# alice addpkg -> gno.land/r/alice/foo +! gnokey maketx addpkg -pkgdir $WORK/mypkg -pkgpath gno.land/r/alice/foo -gas-fee 1000000ugnot -gas-wanted 1000000 -broadcast -chainid=tendermint_test alice +stderr 'unauthorized user' + +# Test admin invites alice +# admin call -> demo/users.Invite +gnokey maketx call -pkgpath gno.land/r/demo/users -func Invite -gas-fee 1000000ugnot -gas-wanted 2500000 -broadcast -chainid=tendermint_test -args $alice_user_addr admin +stdout 'OK!' + +# Alice register alice namespace +# alice call -> demo/users.Register +gnokey maketx call -pkgpath gno.land/r/demo/users -func Register -gas-fee 1000000ugnot -gas-wanted 2500000 -broadcast -chainid=tendermint_test -args $admin_user_addr -args 'alice' -args 'im alice' alice +stdout 'OK!' + +# Alice try to add a pkg on alice namespace +# alice addpkg -> gno.land/r/alice/foo +gnokey maketx addpkg -pkgdir $WORK/mypkg -pkgpath gno.land/r/alice/foo -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test alice +stdout 'OK!' + +# Bob try to add a pkg on alice namespace +# bob addpkg -> gno.land/r/alice/bar +! gnokey maketx addpkg -pkgdir $WORK/mypkg -pkgpath gno.land/r/alice/bar -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test bob +stderr 'unauthorized user' + +# Alice try register a team on a random namespace, should fail +# alice addpkg -> gno.land/r/alice/noop +! gnokey maketx addpkg -pkgdir $WORK/home -pkgpath gno.land/r/alice/noop -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test alice +stderr 'cannot register a team outside a home realm' + +# Alice try register a team on `home` namespace +# alice addpkg -> gno.land/r/alice/home +gnokey maketx addpkg -pkgdir $WORK/home -pkgpath gno.land/r/alice/home -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid=tendermint_test alice +stdout 'OK!' + +# Bob try to add a pkg on alice namespace again +# bob addpkg -> gno.land/r/alice/bar +! gnokey maketx addpkg -pkgdir $WORK/mypkg -pkgpath gno.land/r/alice/bar -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test bob +stderr 'unauthorized user' + +# Bob try to add himself as member +# bob call -> alice/home.AddMember(bob) +! gnokey maketx call -pkgpath gno.land/r/alice/home -func AddMember -gas-fee 1000000ugnot -gas-wanted 2500000 -broadcast -chainid=tendermint_test -args $bob_user_addr bob +stderr 'only members or team address can perform commands on the team' + +# Alice add bob as member +# alice call -> alice/home.AddMember(bob) +gnokey maketx call -pkgpath gno.land/r/alice/home -func AddMember -gas-fee 1000000ugnot -gas-wanted 2500000 -broadcast -chainid=tendermint_test -args $bob_user_addr alice +stdout 'OK!' + +# Bob add a pkg on alice namespace again, success ! +# bob addpkg -> gno.land/r/alice/bar +gnokey maketx addpkg -pkgdir $WORK/mypkg -pkgpath gno.land/r/alice/bar -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test bob +stdout 'OK!' + +-- home/myteam.gno -- +package home + +import ( + "std" + "gno.land/r/sys/teams" +) + +var myteam *teams.Team + +func AddMember(addr std.Address) { + myteam.AddMember(addr) +} + +func init() { + myteam = teams.Register(nil) +} + +-- mypkg/mypkg.gno -- +package mypkg + +func Render(path string) string { + return "# Hello Mypkg" +}