Skip to content

Commit c8ffb58

Browse files
authored
Merge pull request #1201 from getlarge/issue-1199-expose-team-role-management-in-sdk-and
feat: expose team role management in SDK and CLI
2 parents 8f2a105 + 5a8c0df commit c8ffb58

12 files changed

Lines changed: 260 additions & 3 deletions

File tree

apps/moltnet-cli/cobra_teams.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ func newTeamsMembersCmd() *cobra.Command {
6868
}
6969
membersCmd.AddCommand(newTeamsMembersListCmd())
7070
membersCmd.AddCommand(newTeamsMembersRemoveCmd())
71+
membersCmd.AddCommand(newTeamsMembersUpdateRoleCmd())
7172
return membersCmd
7273
}
7374

@@ -99,6 +100,25 @@ func newTeamsMembersRemoveCmd() *cobra.Command {
99100
}
100101
}
101102

103+
func newTeamsMembersUpdateRoleCmd() *cobra.Command {
104+
cmd := &cobra.Command{
105+
Use: "update-role <team-id> <subject-id>",
106+
Short: "Update a member role (owner/manager only)",
107+
Example: ` moltnet teams members update-role 6e4d9948-... 1a2b3c4d-... --role manager
108+
moltnet teams members update-role 6e4d9948-... 1a2b3c4d-... --role member`,
109+
Args: cobra.ExactArgs(2),
110+
RunE: func(cmd *cobra.Command, args []string) error {
111+
credPath, _ := cmd.Flags().GetString("credentials")
112+
apiURL := resolveAPIURL(cmd, credPath)
113+
role, _ := cmd.Flags().GetString("role")
114+
return runTeamsMemberUpdateRoleCmd(apiURL, credPath, args[0], args[1], role)
115+
},
116+
}
117+
cmd.Flags().String("role", "", "Role to assign: member or manager (required)")
118+
_ = cmd.MarkFlagRequired("role")
119+
return cmd
120+
}
121+
102122
func newTeamsCreateCmd() *cobra.Command {
103123
cmd := &cobra.Command{
104124
Use: "create",

apps/moltnet-cli/e2e_team_management_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,81 @@ func TestE2E_CLI_TeamLifecycle(t *testing.T) {
117117
t.Fatalf("invitee agent %s not found in team members", inviteeAgentID)
118118
}
119119

120+
// 4a. owner promotes the invitee to manager, then demotes back to member
121+
stdout, _ = h.run(
122+
t,
123+
"teams",
124+
"members",
125+
"update-role",
126+
teamID.String(),
127+
inviteeAgentID.String(),
128+
"--role",
129+
"manager",
130+
)
131+
var updatedRole struct {
132+
Updated bool `json:"updated"`
133+
Role string `json:"role"`
134+
}
135+
decodeJSON(t, stdout, &updatedRole)
136+
if !updatedRole.Updated || updatedRole.Role != "manager" {
137+
t.Fatalf("promote to manager failed: %+v", updatedRole)
138+
}
139+
140+
membersRes, err = e2eClient.ListTeamMembers(context.Background(),
141+
moltnetapi.ListTeamMembersParams{ID: teamID})
142+
if err != nil {
143+
t.Fatalf("list team members after promote: %v", err)
144+
}
145+
members, ok = membersRes.(*moltnetapi.ListTeamMembersOK)
146+
if !ok {
147+
t.Fatalf("unexpected team members response after promote: %T", membersRes)
148+
}
149+
foundInvitee = false
150+
for _, m := range members.Items {
151+
if m.SubjectId == inviteeAgentID && m.Role == "managers" {
152+
foundInvitee = true
153+
break
154+
}
155+
}
156+
if !foundInvitee {
157+
t.Fatalf("invitee agent %s not promoted to manager", inviteeAgentID)
158+
}
159+
160+
stdout, _ = h.run(
161+
t,
162+
"teams",
163+
"members",
164+
"update-role",
165+
teamID.String(),
166+
inviteeAgentID.String(),
167+
"--role",
168+
"member",
169+
)
170+
decodeJSON(t, stdout, &updatedRole)
171+
if !updatedRole.Updated || updatedRole.Role != "member" {
172+
t.Fatalf("demote to member failed: %+v", updatedRole)
173+
}
174+
175+
membersRes, err = e2eClient.ListTeamMembers(context.Background(),
176+
moltnetapi.ListTeamMembersParams{ID: teamID})
177+
if err != nil {
178+
t.Fatalf("list team members after demote: %v", err)
179+
}
180+
members, ok = membersRes.(*moltnetapi.ListTeamMembersOK)
181+
if !ok {
182+
t.Fatalf("unexpected team members response after demote: %T", membersRes)
183+
}
184+
foundInvitee = false
185+
for _, m := range members.Items {
186+
if m.SubjectId == inviteeAgentID && m.Role == "members" {
187+
foundInvitee = true
188+
break
189+
}
190+
}
191+
if !foundInvitee {
192+
t.Fatalf("invitee agent %s not demoted back to member", inviteeAgentID)
193+
}
194+
120195
// 5. owner grants writer on the shared e2e diary to the invitee
121196
stdout, _ = h.run(t, "diary", "grants", "create", e2eDiaryID.String(),
122197
"--subject-id", inviteeAgentID.String(),

apps/moltnet-cli/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.25.0
55
require (
66
github.com/XiaoConstantine/dspy-go v0.82.2
77
github.com/getlarge/themoltnet/libs/dspy-adapters v0.9.2
8-
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.28.2
8+
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.29.0
99
github.com/go-faster/jx v1.2.0
1010
github.com/google/uuid v1.6.0
1111
github.com/ipfs/go-cid v0.6.0

apps/moltnet-cli/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ github.com/getlarge/themoltnet/libs/dspy-adapters v0.9.2 h1:Rz0SMPs3rNlvIzuMDbc8
3535
github.com/getlarge/themoltnet/libs/dspy-adapters v0.9.2/go.mod h1:LrIk92Gxsga2Ol11P8nV+aa/tM81/kSv7F4MitweIYc=
3636
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.28.2 h1:h4WiBvbxb5v6aGvSrBN685mNVzKHEuGd1D/A7BTDETk=
3737
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.28.2/go.mod h1:2NM6Nj6POFphfql+G6u6/4F2i3EIo5Jd95DBX1lhPLM=
38+
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.29.0 h1:+hdK0p+gdH43qFRmwLsP4ty7d87H0RM9ssn2PCXtKeM=
39+
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.29.0/go.mod h1:2NM6Nj6POFphfql+G6u6/4F2i3EIo5Jd95DBX1lhPLM=
3840
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
3941
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
4042
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=

apps/moltnet-cli/teams.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,37 @@ func runTeamsMemberRemoveCmd(apiURL, credPath, teamID, subjectID string) error {
192192
return printJSON(ok)
193193
}
194194

195+
// runTeamsMemberUpdateRoleCmd updates a team member's role.
196+
func runTeamsMemberUpdateRoleCmd(apiURL, credPath, teamID, subjectID, role string) error {
197+
teamUUID, err := uuid.Parse(teamID)
198+
if err != nil {
199+
return fmt.Errorf("invalid team ID %q: %w", teamID, err)
200+
}
201+
subjectUUID, err := uuid.Parse(subjectID)
202+
if err != nil {
203+
return fmt.Errorf("invalid subject ID %q: %w", subjectID, err)
204+
}
205+
client, err := newClientFromCreds(apiURL, credPath)
206+
if err != nil {
207+
return err
208+
}
209+
req := &moltnetapi.UpdateTeamMemberRoleReq{
210+
Role: moltnetapi.UpdateTeamMemberRoleReqRole(role),
211+
}
212+
res, err := client.UpdateTeamMemberRole(context.Background(), req, moltnetapi.UpdateTeamMemberRoleParams{
213+
ID: teamUUID,
214+
SubjectId: subjectUUID,
215+
})
216+
if err != nil {
217+
return fmt.Errorf("teams members update-role: %w", formatTransportError(err))
218+
}
219+
updated, ok := res.(*moltnetapi.UpdateTeamMemberRoleOK)
220+
if !ok {
221+
return formatAPIError(res)
222+
}
223+
return printJSON(updated)
224+
}
225+
195226
// runTeamsInviteDeleteCmd deletes a team invite code.
196227
func runTeamsInviteDeleteCmd(apiURL, credPath, teamID, inviteID string) error {
197228
teamUUID, err := uuid.Parse(teamID)

apps/moltnet-cli/teams_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func TestTeamsMembersHelp(t *testing.T) {
4747
if err != nil {
4848
t.Fatalf("unexpected error: %v", err)
4949
}
50-
for _, sub := range []string{"list", "remove"} {
50+
for _, sub := range []string{"list", "remove", "update-role"} {
5151
if !strings.Contains(stdout, sub) {
5252
t.Errorf("expected members help to contain %q, got: %s", sub, stdout)
5353
}
@@ -76,6 +76,53 @@ func TestTeamsMembersRemoveRequiresArgs(t *testing.T) {
7676
}
7777
}
7878

79+
func TestTeamsMembersUpdateRoleRequiresArgs(t *testing.T) {
80+
t.Parallel()
81+
root := NewRootCmd("test", "")
82+
_, _, err := executeCommand(root, "teams", "members", "update-role")
83+
if err == nil {
84+
t.Fatal("expected error when team and subject IDs are missing")
85+
}
86+
_, _, err = executeCommand(root, "teams", "members", "update-role", "team-id-only")
87+
if err == nil {
88+
t.Fatal("expected error when subject ID is missing")
89+
}
90+
}
91+
92+
func TestTeamsMembersUpdateRoleRequiresRoleFlag(t *testing.T) {
93+
t.Parallel()
94+
root := NewRootCmd("test", "")
95+
_, _, err := executeCommand(
96+
root,
97+
"teams",
98+
"members",
99+
"update-role",
100+
"00000000-0000-0000-0000-000000000000",
101+
"00000000-0000-0000-0000-000000000000",
102+
)
103+
if err == nil {
104+
t.Fatal("expected error when --role is missing")
105+
}
106+
if !strings.Contains(err.Error(), "role") {
107+
t.Errorf("expected error to mention --role, got: %v", err)
108+
}
109+
}
110+
111+
func TestTeamsMembersUpdateRoleHelp(t *testing.T) {
112+
t.Parallel()
113+
root := NewRootCmd("test", "")
114+
stdout, _, err := executeCommand(root, "teams", "members", "update-role", "--help")
115+
if err != nil {
116+
t.Fatalf("unexpected error: %v", err)
117+
}
118+
if !strings.Contains(stdout, "--role") {
119+
t.Errorf("expected update-role help to contain --role, got: %s", stdout)
120+
}
121+
if !strings.Contains(stdout, "manager") || !strings.Contains(stdout, "member") {
122+
t.Errorf("expected update-role help to mention member and manager roles, got: %s", stdout)
123+
}
124+
}
125+
79126
func TestTeamsDeleteRequiresArg(t *testing.T) {
80127
t.Parallel()
81128
root := NewRootCmd("test", "")

docs/use/teams.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Invites can be listed (`teams_invite_list`) and revoked (`teams_invite_delete`)
3636

3737
### Managing members
3838

39-
Owners and managers can remove members with `teams_member_remove`. Owners can't be removed by anyone except themselves — ownership transfer is an explicit, symmetrical operation, not a demotion.
39+
Owners and managers can update a member's role between `member` and `manager` with `updateTeamMemberRole` / `teams members update-role`, and remove members with `teams_member_remove`. Owners can't be removed by anyone except themselves — ownership transfer is an explicit, symmetrical operation, not a demotion.
4040

4141
## Groups
4242

go.work.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xW
1919
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
2020
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
2121
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.27.0/go.mod h1:2NM6Nj6POFphfql+G6u6/4F2i3EIo5Jd95DBX1lhPLM=
22+
github.com/getlarge/themoltnet/libs/moltnet-api-client v1.29.0/go.mod h1:2NM6Nj6POFphfql+G6u6/4F2i3EIo5Jd95DBX1lhPLM=
2223
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
2324
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
2425
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=

libs/sdk/__tests__/agent.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
updateContextPack,
5454
updateDiary,
5555
updateDiaryEntryById,
56+
updateTeamMemberRole,
5657
verifyAgentSignature,
5758
verifyCryptoSignature,
5859
verifyDiaryEntryById,
@@ -115,6 +116,7 @@ vi.mock('@moltnet/api-client', async (importOriginal) => {
115116
joinTeam: vi.fn(),
116117
deleteTeam: vi.fn(),
117118
removeTeamMember: vi.fn(),
119+
updateTeamMemberRole: vi.fn(),
118120
createTeamInvite: vi.fn(),
119121
listTeamInvites: vi.fn(),
120122
deleteTeamInvite: vi.fn(),
@@ -1317,6 +1319,29 @@ describe('Agent facade', () => {
13171319
}),
13181320
);
13191321
});
1322+
1323+
it('teams.updateMemberRole sends path params and role body', async () => {
1324+
const updated = { updated: true, role: 'manager' };
1325+
vi.mocked(updateTeamMemberRole).mockResolvedValueOnce({
1326+
data: updated,
1327+
error: undefined,
1328+
} as any);
1329+
1330+
const agent = makeAgent();
1331+
const result = await agent.teams.updateMemberRole(
1332+
'team-1',
1333+
'subject-1',
1334+
'manager',
1335+
);
1336+
1337+
expect(result).toEqual(updated);
1338+
expect(updateTeamMemberRole).toHaveBeenCalledWith(
1339+
expect.objectContaining({
1340+
path: { id: 'team-1', subjectId: 'subject-1' },
1341+
body: { role: 'manager' },
1342+
}),
1343+
);
1344+
});
13201345
});
13211346

13221347
// -----------------------------------------------------------------------

libs/sdk/src/agent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ import type {
100100
UpdateDiaryData,
101101
UpdateDiaryEntryByIdData,
102102
UpdateRenderedPackData,
103+
UpdateTeamMemberRoleData,
104+
UpdateTeamMemberRoleResponse,
103105
VerifyResult,
104106
Voucher,
105107
} from '@moltnet/api-client';
@@ -386,6 +388,11 @@ export interface TeamsNamespace {
386388
teamId: string,
387389
subjectId: string,
388390
): Promise<RemoveTeamMemberResponse>;
391+
updateMemberRole(
392+
teamId: string,
393+
subjectId: string,
394+
role: UpdateTeamMemberRoleData['body']['role'],
395+
): Promise<UpdateTeamMemberRoleResponse>;
389396
invites: {
390397
create(
391398
teamId: string,

0 commit comments

Comments
 (0)