diff --git a/docs/ICD/personas.md b/docs/ICD/personas.md index 7188c1b..79d40eb 100644 --- a/docs/ICD/personas.md +++ b/docs/ICD/personas.md @@ -5,16 +5,6 @@ prompt + model + provider config + KB bindings + grant scoping. Users interact with Personas, not raw provider configs. Admins curate which Personas are available; team admins create team-scoped Personas. -**Model inheritance:** A Persona inherits all properties of its -underlying model. This includes context window size, max output -tokens, supported capabilities (thinking, tools, vision, streaming), -input/output pricing, and provider status. When the frontend displays -a Persona, it resolves the underlying model's capabilities and shows -the same pills/badges (e.g. thinking, tools, 200k context) that the -raw model would show. The Persona adds identity (name, avatar, system -prompt, KB bindings) on top of the model's capabilities โ it never -masks or reduces them. - Three scopes, three route namespaces, one domain: | Scope | Routes | Who manages | @@ -29,67 +19,78 @@ All three return the same Persona object shape: { "id": "uuid", "name": "Code Reviewer", + "handle": "code-reviewer", "description": "Reviews code for bugs and style", - "system_prompt": "You are a senior code reviewer...", - "model": "claude-sonnet-4-20250514", + "icon": "๐", + "avatar": "data:image/png;base64,...", + "base_model_id": "claude-sonnet-4-20250514", "provider_config_id": "uuid|null", + "system_prompt": "You are a senior code reviewer...", + "temperature": 0.7, + "max_tokens": 4096, + "thinking_budget": null, + "top_p": null, "scope": "personal|team|global", "owner_id": "uuid|null", - "is_default": false, + "created_by": "uuid", "is_active": true, - "avatar_url": "/api/v1/personas/uuid/avatar?v=123", - "inherited": { - "context_window": 200000, - "max_output_tokens": 8192, - "supports_vision": true, - "supports_tools": true, - "supports_thinking": true, - "supports_streaming": true, - "input_price_per_m": 3.00, - "output_price_per_m": 15.00, - "provider_status": "healthy|degraded|down|null" - }, + "is_shared": false, + "memory_enabled": false, + "memory_extraction_prompt": null, + "grants": [], + "kb_ids": [], "created_at": "...", "updated_at": "..." } ``` -The `inherited` block is populated by the backend from the resolved -model capabilities at response time โ it is not stored on the persona. -If the underlying model's capabilities change (e.g. provider upgrades -context window), the persona inherits the new values automatically. -The frontend uses `inherited` to render capability pills and context -size badges identical to the raw model. +`grants` and `kb_ids` are loaded from junction tables, not stored on the +persona row. All nullable fields (`provider_config_id`, `owner_id`, +`temperature`, `max_tokens`, `thinking_budget`, `top_p`, +`memory_extraction_prompt`) are omitted from JSON when null. + +`handle` is auto-generated from `name` on creation if not provided +(e.g. "Code Reviewer" โ "code-reviewer"). Used for @mention routing. ### Personal Personas Gated by `allow_user_personas` policy. ``` -GET /personas โ { "personas": [...] } -POST /personas โ { "name", "description", "system_prompt", "model", ... } +GET /personas โ { "data": [...] } +POST /personas โ { "name", "base_model_id", ... } PUT /personas/:id โ partial update DELETE /personas/:id ``` +**Auth:** JWT required. `POST` requires `persona:create` permission. +`PUT`/`DELETE` require `persona:manage` permission and ownership check +(`scope == "personal"` and `owner_id == caller`). + ### Team Personas ``` -GET /teams/:teamId/personas โ { "personas": [...] } +GET /teams/:teamId/personas โ { "data": [...] } POST /teams/:teamId/personas โ same shape -PUT /teams/:teamId/personas/:id +PUT /teams/:teamId/personas/:id โ partial update DELETE /teams/:teamId/personas/:id ``` +**Auth:** JWT required. Team membership for `GET`, team admin for +`POST`/`PUT`/`DELETE`. All mutating endpoints verify the persona belongs +to the team in the URL path (`scope == "team"` and `owner_id == teamId`). + ### Admin (Global) Personas ``` -GET /admin/personas โ { "personas": [...] } +GET /admin/personas โ { "data": [...] } POST /admin/personas โ same shape PUT /admin/personas/:id DELETE /admin/personas/:id ``` +**Auth:** Admin JWT required (middleware enforced). + ### Persona KB Bindings A Persona can have Knowledge Bases bound to it. When a channel uses @@ -127,23 +128,41 @@ PUT /admin/personas/:id/knowledge-bases context). When `false`, it's available via the `kb_search` tool but not auto-injected. +Team-scoped KB binding endpoints verify the persona belongs to the +team in the URL path before reading or writing bindings. + ### Persona Avatars +Avatars are stored as `data:image/png;base64,...` data URIs in the +`avatar` column. Upload accepts a JSON body (not multipart): + +```json +{ "image": "data:image/png;base64,..." } ``` -POST /personas/:id/avatar โ multipart/form-data (field: "avatar") + +The image is decoded, resized to 128ร128 PNG, and stored. + +``` +POST /personas/:id/avatar โ { "image": "base64..." } DELETE /personas/:id/avatar -POST /admin/personas/:id/avatar +POST /admin/personas/:id/avatar โ same DELETE /admin/personas/:id/avatar +POST /teams/:teamId/personas/:id/avatar โ same (verify team ownership) +DELETE /teams/:teamId/personas/:id/avatar ``` +**Auth:** Personal avatar routes verify the caller owns the persona +(`scope == "personal"` and `owner_id == caller`). Admin routes are +protected by admin middleware. + ### Persona Tool Grants Control which tools a persona can use during completions. The completion handler applies tool grants as a second-pass allowlist. ``` -GET /personas/:id/tool-grants โ { "grants": ["web_search", "kb_search", ...] } -PUT /personas/:id/tool-grants โ { "tool_ids": ["web_search", "calculator"] } +GET /personas/:id/tool-grants โ { "data": ["web_search", "kb_search", ...] } +PUT /personas/:id/tool-grants โ { "tool_names": ["web_search", "calculator"] } ``` Admin equivalents: @@ -153,6 +172,13 @@ GET /admin/personas/:id/tool-grants PUT /admin/personas/:id/tool-grants ``` +Team equivalents (verify persona belongs to team): + +``` +GET /teams/:teamId/personas/:id/tool-grants +PUT /teams/:teamId/personas/:id/tool-grants +``` + When a workflow version is published, persona tool grants at that moment are frozen into the version snapshot. @@ -163,7 +189,7 @@ can be stamped onto new conversations. ``` GET /persona-groups โ { "data": [...] } -POST /persona-groups โ { "name", "description", "scope", "team_id" } +POST /persona-groups โ { "name", "description" } GET /persona-groups/:id โ group with members PUT /persona-groups/:id โ partial update DELETE /persona-groups/:id @@ -172,12 +198,13 @@ DELETE /persona-groups/:id **Members:** ``` -POST /persona-groups/:id/members โ { "persona_id", "is_leader": false, "sort_order": 0 } +POST /persona-groups/:id/members โ { "persona_id", "is_leader": false } DELETE /persona-groups/:id/members/:memberId ``` `is_leader`: the leader persona responds when no @mention is present in -a group channel. Only one leader per group. +a group channel. Only one leader per group. Setting a new leader +automatically clears the existing one. **Persona Group Object:** @@ -187,18 +214,30 @@ a group channel. Only one leader per group. "name": "Support Team", "description": "...", "owner_id": "uuid", - "scope": "personal|team|global", - "team_id": "uuid|null", + "scope": "personal", + "team_id": null, "members": [ - { "id": "uuid", "persona_id": "uuid", "is_leader": true, "sort_order": 0 } + { + "id": "uuid", + "group_id": "uuid", + "persona_id": "uuid", + "is_leader": true, + "sort_order": 0, + "persona_name": "...", + "persona_handle": "...", + "persona_avatar": "..." + } ], "created_at": "...", "updated_at": "..." } ``` +**Note:** Persona groups are currently personal-scope only. The `scope` +and `team_id` fields exist in the schema but `POST` hardcodes +`scope = 'personal'`. Team-scoped persona groups are a future item. + ### Resource Grants See [teams.md](teams.md) ยง15.3 for the grant system. Personas use grants to control who can see and use them beyond their base scope. - diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index c3ab0d2..3263847 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -214,6 +214,19 @@ func setupHarness(t *testing.T) *testHarness { teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete) teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow) teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun) + + // Team personas (v0.28.0-audit) + teamPersonas := NewPersonaHandler(stores) + teamScoped.GET("/personas", teamPersonas.ListTeamPersonas) + teamScoped.POST("/personas", teamPersonas.CreateTeamPersona) + teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona) + teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona) + teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) + teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) + teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) + teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) + teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) + teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) } // Team task viewing for all members (v0.28.0-audit) @@ -240,8 +253,24 @@ func setupHarness(t *testing.T) *testHarness { personas := NewPersonaHandler(stores) protected.GET("/personas", personas.ListUserPersonas) protected.POST("/personas", personas.CreateUserPersona) + protected.PUT("/personas/:id", personas.UpdateUserPersona) + protected.DELETE("/personas/:id", personas.DeleteUserPersona) + protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar) + protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar) protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 + protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 + protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0 + + // Persona Groups + pgH := NewPersonaGroupHandler() + protected.GET("/persona-groups", pgH.List) + protected.POST("/persona-groups", pgH.Create) + protected.GET("/persona-groups/:id", pgH.Get) + protected.PUT("/persona-groups/:id", pgH.Update) + protected.DELETE("/persona-groups/:id", pgH.Delete) + protected.POST("/persona-groups/:id/members", pgH.AddMember) + protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember) // Notes notes := NewNoteHandler(stores) @@ -340,8 +369,14 @@ func setupHarness(t *testing.T) *testHarness { admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember) admin.GET("/personas", personas.ListAdminPersonas) admin.POST("/personas", personas.CreateAdminPersona) + admin.PUT("/personas/:id", personas.UpdateAdminPersona) + admin.DELETE("/personas/:id", personas.DeleteAdminPersona) + admin.POST("/personas/:id/avatar", UploadPersonaAvatar) + admin.DELETE("/personas/:id/avatar", DeletePersonaAvatar) admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 + admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 + admin.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0 // Admin groups (v0.16.0) groupAdm := NewGroupHandler(stores) @@ -2921,7 +2956,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) { w = h.request("GET", "/api/v1/personas", userToken, nil) var personasResp map[string]interface{} decode(w, &personasResp) - personas, _ := personasResp["personas"].([]interface{}) + personas, _ := personasResp["data"].([]interface{}) for _, p := range personas { pm := p.(map[string]interface{}) if pm["id"] == personaID { @@ -2955,7 +2990,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) { t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String()) } decode(w, &personasResp) - personas, _ = personasResp["personas"].([]interface{}) + personas, _ = personasResp["data"].([]interface{}) found := false for _, p := range personas { pm := p.(map[string]interface{}) @@ -2973,7 +3008,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) { w = h.request("GET", "/api/v1/personas", userToken, nil) decode(w, &personasResp) - personas, _ = personasResp["personas"].([]interface{}) + personas, _ = personasResp["data"].([]interface{}) for _, p := range personas { pm := p.(map[string]interface{}) if pm["id"] == personaID { @@ -3979,3 +4014,828 @@ func mapKeys(m map[string]interface{}) []string { } return keys } + +// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +// PERSONA AUDIT TESTS (v0.28.0-audit CS2) +// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +// โโ Persona CRUD โโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +func TestPersona_CRUD_Personal(t *testing.T) { + h := setupHarness(t) + adminID, adminToken := h.createAdminUser("padmin", "padmin@test.com") + _ = adminID + + // Enable persona creation policy + database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") + + // Create + w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ + "name": "My Helper", "base_model_id": "test-model", + "system_prompt": "You are helpful.", "description": "Test persona", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String()) + } + var created map[string]interface{} + decode(w, &created) + personaID := created["id"].(string) + if created["handle"] == nil || created["handle"] == "" { + t.Error("handle should be auto-generated from name") + } + + // Update + w = h.request("PUT", "/api/v1/personas/"+personaID, adminToken, map[string]interface{}{ + "name": "My Updated Helper", "description": "Updated desc", + }) + if w.Code != http.StatusOK { + t.Fatalf("update persona: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // List โ verify updated name appears + w = h.request("GET", "/api/v1/personas", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String()) + } + var listResp map[string]interface{} + decode(w, &listResp) + data := listResp["data"].([]interface{}) + found := false + for _, p := range data { + pm := p.(map[string]interface{}) + if pm["id"] == personaID && pm["name"] == "My Updated Helper" { + found = true + } + } + if !found { + t.Error("updated persona not found in list with new name") + } + + // Delete + w = h.request("DELETE", "/api/v1/personas/"+personaID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete persona: want 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPersona_Update_OwnershipCheck(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("own1", "own1@test.com") + otherID := database.SeedTestUser(t, "own2", "own2@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID) + otherToken := makeToken(otherID, "own2@test.com", "user") + + // Enable persona creation + database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") + + // Admin creates a personal persona + w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ + "name": "Admin's Bot", "base_model_id": "test-model", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String()) + } + var created map[string]interface{} + decode(w, &created) + personaID := created["id"].(string) + + // Other user tries to update โ should be forbidden + w = h.request("PUT", "/api/v1/personas/"+personaID, otherToken, map[string]interface{}{ + "name": "Stolen", + }) + if w.Code != http.StatusForbidden { + t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Other user tries to delete โ should be forbidden + w = h.request("DELETE", "/api/v1/personas/"+personaID, otherToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("delete by non-owner: want 403, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPersona_AdminCRUD(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("apadmin", "apadmin@test.com") + + // Create global persona via admin + w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ + "name": "Global Bot", "base_model_id": "test-model", + "system_prompt": "You are global.", + }) + if w.Code != http.StatusCreated { + t.Fatalf("admin create: want 201, got %d: %s", w.Code, w.Body.String()) + } + var created map[string]interface{} + decode(w, &created) + personaID := created["id"].(string) + if created["scope"] != "global" { + t.Fatalf("admin persona scope: want 'global', got %v", created["scope"]) + } + + // Update + w = h.request("PUT", "/api/v1/admin/personas/"+personaID, adminToken, map[string]interface{}{ + "name": "Global Bot v2", + }) + if w.Code != http.StatusOK { + t.Fatalf("admin update: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // List + w = h.request("GET", "/api/v1/admin/personas", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String()) + } + var listResp map[string]interface{} + decode(w, &listResp) + if listResp["data"] == nil { + t.Fatal("admin list should return 'data' key") + } + + // Delete + w = h.request("DELETE", "/api/v1/admin/personas/"+personaID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// โโ Envelope + Nil Slice โโโโโโโโโโโโโโโโโโโโ + +func TestPersona_EnvelopeKey(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("envadm", "envadm@test.com") + + // User list + w := h.request("GET", "/api/v1/personas", adminToken, nil) + var resp map[string]interface{} + decode(w, &resp) + if _, ok := resp["personas"]; ok { + t.Error("response should use 'data' key, not 'personas'") + } + if resp["data"] == nil { + t.Error("response should have 'data' key") + } + + // Admin list + w = h.request("GET", "/api/v1/admin/personas", adminToken, nil) + decode(w, &resp) + if _, ok := resp["personas"]; ok { + t.Error("admin response should use 'data' key, not 'personas'") + } + if resp["data"] == nil { + t.Error("admin response should have 'data' key") + } +} + +func TestPersona_NilSlice_ReturnsEmptyArray(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("niladm", "niladm@test.com") + + // Fresh user, no personas โ should get [] not null + w := h.request("GET", "/api/v1/personas", adminToken, nil) + // Check raw JSON contains [] not null + body := w.Body.String() + if strings.Contains(body, `"data":null`) { + t.Fatal("data should be [] not null for empty persona list") + } + if !strings.Contains(body, `"data":[]`) { + t.Fatalf("data should be empty array, got: %s", body) + } + + // Admin list + w = h.request("GET", "/api/v1/admin/personas", adminToken, nil) + body = w.Body.String() + if strings.Contains(body, `"data":null`) { + t.Fatal("admin data should be [] not null for empty persona list") + } +} + +// โโ Tool Grants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +func TestPersona_ToolGrants_RoundTrip(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("tgadm", "tgadm@test.com") + + // Create persona + w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ + "name": "Grant Bot", "base_model_id": "test-model", + }) + var created map[string]interface{} + decode(w, &created) + personaID := created["id"].(string) + + // Initially empty + w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } + var grantResp map[string]interface{} + decode(w, &grantResp) + grants := grantResp["data"].([]interface{}) + if len(grants) != 0 { + t.Fatalf("initial grants: want 0, got %d", len(grants)) + } + + // Set grants + w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{ + "tool_names": []string{"web_search", "calculator", "kb_search"}, + }) + if w.Code != http.StatusOK { + t.Fatalf("set tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read back + w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil) + decode(w, &grantResp) + grants = grantResp["data"].([]interface{}) + if len(grants) != 3 { + t.Fatalf("grants after set: want 3, got %d", len(grants)) + } + + // Clear grants + w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{ + "tool_names": []string{}, + }) + if w.Code != http.StatusOK { + t.Fatalf("clear tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read back โ should be empty + w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil) + decode(w, &grantResp) + grants = grantResp["data"].([]interface{}) + if len(grants) != 0 { + t.Fatalf("grants after clear: want 0, got %d", len(grants)) + } + + // Nil-slice check: verify "data":[] not "data":null + body := w.Body.String() + if strings.Contains(body, `"data":null`) { + t.Fatal("tool grants should return [] not null when empty") + } +} + +func TestPersona_ToolGrants_UserScope(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("tguadm", "tguadm@test.com") + + database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") + + // Create personal persona + w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ + "name": "User Grant Bot", "base_model_id": "test-model", + }) + var created map[string]interface{} + decode(w, &created) + personaID := created["id"].(string) + + // Set via user endpoint + w = h.request("PUT", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{ + "tool_names": []string{"web_search"}, + }) + if w.Code != http.StatusOK { + t.Fatalf("user set tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read back via user endpoint + w = h.request("GET", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("user get tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } + var grantResp map[string]interface{} + decode(w, &grantResp) + grants := grantResp["data"].([]interface{}) + if len(grants) != 1 { + t.Fatalf("user grants: want 1, got %d", len(grants)) + } +} + +// โโ Team Persona CRUD + Scope Checks โโโโโโโโ + +func TestTeamPersona_CRUD(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("tpadm", "tpadm@test.com") + + // Create a team + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "Persona Team", + }) + var teamResp map[string]interface{} + decode(w, &teamResp) + teamID := teamResp["id"].(string) + + // Create team persona + w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{ + "name": "Team Bot", "base_model_id": "test-model", + "system_prompt": "You are a team bot.", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create team persona: want 201, got %d: %s", w.Code, w.Body.String()) + } + var created map[string]interface{} + decode(w, &created) + personaID := created["id"].(string) + if created["scope"] != "team" { + t.Fatalf("team persona scope: want 'team', got %v", created["scope"]) + } + + // List team personas + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list team personas: want 200, got %d: %s", w.Code, w.Body.String()) + } + var listResp map[string]interface{} + decode(w, &listResp) + if listResp["data"] == nil { + t.Fatal("team persona list should use 'data' key") + } + items := listResp["data"].([]interface{}) + if len(items) != 1 { + t.Fatalf("team personas: want 1, got %d", len(items)) + } + + // Update team persona + w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, map[string]interface{}{ + "name": "Team Bot v2", + }) + if w.Code != http.StatusOK { + t.Fatalf("update team persona: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Delete team persona + w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete team persona: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify deleted + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil) + decode(w, &listResp) + items = listResp["data"].([]interface{}) + if len(items) != 0 { + t.Fatalf("team personas after delete: want 0, got %d", len(items)) + } +} + +func TestTeamPersona_Delete_ScopeCheck(t *testing.T) { + h := setupHarness(t) + adminID, adminToken := h.createAdminUser("tpscadm", "tpscadm@test.com") + + // Create two teams + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "Team Alpha", + }) + var teamA map[string]interface{} + decode(w, &teamA) + teamAID := teamA["id"].(string) + + w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "Team Beta", + }) + var teamB map[string]interface{} + decode(w, &teamB) + teamBID := teamB["id"].(string) + + // Create persona on Team Alpha + personaID := seedInsertReturningID(t, ` + INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) + VALUES ('Alpha Bot', 'test-model', 'team', $1, $2, true) + RETURNING id + `, teamAID, adminID) + + // Try to delete it via Team Beta route โ should fail + w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-team delete: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Try to update it via Team Beta route โ should fail + w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, map[string]interface{}{ + "name": "Stolen", + }) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-team update: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Delete via correct team โ should succeed + w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamAID, personaID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("same-team delete: want 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTeamPersona_KB_ScopeCheck(t *testing.T) { + h := setupHarness(t) + adminID, adminToken := h.createAdminUser("tpkbadm", "tpkbadm@test.com") + + // Create two teams + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "KB Team A", + }) + var teamA map[string]interface{} + decode(w, &teamA) + teamAID := teamA["id"].(string) + + w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "KB Team B", + }) + var teamB map[string]interface{} + decode(w, &teamB) + teamBID := teamB["id"].(string) + + // Create persona on Team A + personaID := seedInsertReturningID(t, ` + INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) + VALUES ('KB Bot', 'test-model', 'team', $1, $2, true) + RETURNING id + `, teamAID, adminID) + + // Try to read KBs via Team B โ should fail + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-team get KBs: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Try to set KBs via Team B โ should fail + w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, map[string]interface{}{ + "kb_ids": []string{}, + }) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-team set KBs: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Read via correct team โ should succeed + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamAID, personaID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("same-team get KBs: want 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// โโ Persona Groups โโโโโโโโโโโโโโโโโโโโโโโโโโ + +func TestPersonaGroup_CRUD(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("pgadm", "pgadm@test.com") + + // Create group + w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{ + "name": "Support Team", "description": "Front-line support personas", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String()) + } + var created map[string]interface{} + decode(w, &created) + groupID := created["id"].(string) + if created["scope"] != "personal" { + t.Fatalf("group scope: want 'personal', got %v", created["scope"]) + } + + // Get + w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get group: want 200, got %d: %s", w.Code, w.Body.String()) + } + var fetched map[string]interface{} + decode(w, &fetched) + if fetched["name"] != "Support Team" { + t.Fatalf("group name: want 'Support Team', got %v", fetched["name"]) + } + + // Update + w = h.request("PUT", "/api/v1/persona-groups/"+groupID, adminToken, map[string]interface{}{ + "name": "Support Team v2", + }) + if w.Code != http.StatusOK { + t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // List + w = h.request("GET", "/api/v1/persona-groups", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list groups: want 200, got %d: %s", w.Code, w.Body.String()) + } + var listResp map[string]interface{} + decode(w, &listResp) + if listResp["data"] == nil { + t.Fatal("group list should use 'data' key") + } + if _, ok := listResp["groups"]; ok { + t.Error("group list should NOT use 'groups' key") + } + + // Delete + w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete group: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify deleted + w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("get deleted group: want 404, got %d", w.Code) + } +} + +func TestPersonaGroup_Members(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("pgmadm", "pgmadm@test.com") + + // Create a persona for the group + w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ + "name": "Group Persona A", "base_model_id": "test-model", + }) + var pA map[string]interface{} + decode(w, &pA) + personaAID := pA["id"].(string) + + w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ + "name": "Group Persona B", "base_model_id": "test-model", + }) + var pB map[string]interface{} + decode(w, &pB) + personaBID := pB["id"].(string) + + // Create group + w = h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{ + "name": "Two-Bot Team", + }) + var group map[string]interface{} + decode(w, &group) + groupID := group["id"].(string) + + // Add member A as leader + w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{ + "persona_id": personaAID, "is_leader": true, + }) + if w.Code != http.StatusCreated { + t.Fatalf("add member A: want 201, got %d: %s", w.Code, w.Body.String()) + } + + // Add member B + w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{ + "persona_id": personaBID, + }) + if w.Code != http.StatusCreated { + t.Fatalf("add member B: want 201, got %d: %s", w.Code, w.Body.String()) + } + + // Get group โ should have 2 members + w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get group for members: want 200, got %d: %s", w.Code, w.Body.String()) + } + var fetched map[string]interface{} + decode(w, &fetched) + members := fetched["members"].([]interface{}) + if len(members) != 2 { + t.Fatalf("members: want 2, got %d", len(members)) + } + + // Verify leader is first (sorted by is_leader DESC) + first := members[0].(map[string]interface{}) + if first["is_leader"] != true { + t.Error("first member should be the leader") + } + + // Swap leader to B โ should clear A's leader flag + w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{ + "persona_id": personaBID, "is_leader": true, + }) + if w.Code != http.StatusCreated { + t.Fatalf("swap leader: want 201, got %d: %s", w.Code, w.Body.String()) + } + + // Get group โ verify only one leader + w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) + decode(w, &fetched) + members = fetched["members"].([]interface{}) + leaderCount := 0 + for _, m := range members { + mm := m.(map[string]interface{}) + if mm["is_leader"] == true { + leaderCount++ + } + } + if leaderCount != 1 { + t.Fatalf("leaders after swap: want 1, got %d", leaderCount) + } + + // Remove member A + memberAID := "" + for _, m := range members { + mm := m.(map[string]interface{}) + if mm["persona_id"] == personaAID { + memberAID = mm["id"].(string) + } + } + if memberAID == "" { + t.Fatal("could not find member A in group") + } + w = h.request("DELETE", fmt.Sprintf("/api/v1/persona-groups/%s/members/%s", groupID, memberAID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Get group โ should have 1 member + w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) + decode(w, &fetched) + members = fetched["members"].([]interface{}) + if len(members) != 1 { + t.Fatalf("members after remove: want 1, got %d", len(members)) + } +} + +func TestPersonaGroup_OwnershipCheck(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("pgown1", "pgown1@test.com") + otherID := database.SeedTestUser(t, "pgown2", "pgown2@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID) + otherToken := makeToken(otherID, "pgown2@test.com", "user") + + // Admin creates a group + w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{ + "name": "Admin's Group", + }) + var group map[string]interface{} + decode(w, &group) + groupID := group["id"].(string) + + // Other user tries to update โ should fail + w = h.request("PUT", "/api/v1/persona-groups/"+groupID, otherToken, map[string]interface{}{ + "name": "Stolen Group", + }) + if w.Code != http.StatusForbidden { + t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Other user tries to delete โ should fail + w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, otherToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("delete by non-owner: want 404, got %d: %s", w.Code, w.Body.String()) + } + + // Other user list โ should not see admin's group + w = h.request("GET", "/api/v1/persona-groups", otherToken, nil) + var listResp map[string]interface{} + decode(w, &listResp) + items := listResp["data"].([]interface{}) + if len(items) != 0 { + t.Fatalf("non-owner should see 0 groups, got %d", len(items)) + } +} + +func TestPersonaGroup_EnvelopeKey(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("pgeadm", "pgeadm@test.com") + + w := h.request("GET", "/api/v1/persona-groups", adminToken, nil) + var resp map[string]interface{} + decode(w, &resp) + + if _, ok := resp["groups"]; ok { + t.Error("persona-groups list should use 'data' key, not 'groups'") + } + if resp["data"] == nil { + t.Error("persona-groups list should have 'data' key") + } +} + +// โโ Team Persona Tool Grants โโโโโโโโโโโโโโโโ + +func TestTeamPersona_ToolGrants_RoundTrip(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("ttgadm", "ttgadm@test.com") + + // Create team + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "ToolGrant Team", + }) + var team map[string]interface{} + decode(w, &team) + teamID := team["id"].(string) + + // Create team persona + w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{ + "name": "TG Bot", "base_model_id": "test-model", + }) + var created map[string]interface{} + decode(w, &created) + personaID := created["id"].(string) + + // Initially empty + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get team tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + decode(w, &resp) + grants := resp["data"].([]interface{}) + if len(grants) != 0 { + t.Fatalf("initial team grants: want 0, got %d", len(grants)) + } + + // Set + w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, map[string]interface{}{ + "tool_names": []string{"web_search", "kb_search"}, + }) + if w.Code != http.StatusOK { + t.Fatalf("set team tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read back + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil) + decode(w, &resp) + grants = resp["data"].([]interface{}) + if len(grants) != 2 { + t.Fatalf("team grants after set: want 2, got %d", len(grants)) + } +} + +func TestTeamPersona_ToolGrants_ScopeCheck(t *testing.T) { + h := setupHarness(t) + adminID, adminToken := h.createAdminUser("ttgsadm", "ttgsadm@test.com") + + // Create two teams + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "TG Scope A", + }) + var teamA map[string]interface{} + decode(w, &teamA) + teamAID := teamA["id"].(string) + + w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "TG Scope B", + }) + var teamB map[string]interface{} + decode(w, &teamB) + teamBID := teamB["id"].(string) + + // Create persona on Team A + personaID := seedInsertReturningID(t, ` + INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) + VALUES ('TG Scope Bot', 'test-model', 'team', $1, $2, true) + RETURNING id + `, teamAID, adminID) + + // Try to read via Team B โ 403 + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-team get tool grants: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Try to set via Team B โ 403 + w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, map[string]interface{}{ + "tool_names": []string{"web_search"}, + }) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-team set tool grants: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Via correct team โ 200 + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamAID, personaID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("same-team get tool grants: want 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// โโ Team Persona Avatar โโโโโโโโโโโโโโโโโโโโโ + +func TestTeamPersona_Avatar_ScopeCheck(t *testing.T) { + h := setupHarness(t) + adminID, adminToken := h.createAdminUser("taadm", "taadm@test.com") + + // Create two teams + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "Avatar Team A", + }) + var teamA map[string]interface{} + decode(w, &teamA) + teamAID := teamA["id"].(string) + + w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ + "name": "Avatar Team B", + }) + var teamB map[string]interface{} + decode(w, &teamB) + teamBID := teamB["id"].(string) + + // Create persona on Team A + personaID := seedInsertReturningID(t, ` + INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) + VALUES ('Avatar Bot', 'test-model', 'team', $1, $2, true) + RETURNING id + `, teamAID, adminID) + + // Try to delete avatar via Team B โ 403 + w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamBID, personaID), adminToken, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-team delete avatar: want 403, got %d: %s", w.Code, w.Body.String()) + } + + // Try to delete via correct team โ should succeed (persona has no avatar, so 404 from UPDATE rows=0 is fine too) + w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamAID, personaID), adminToken, nil) + if w.Code != http.StatusOK && w.Code != http.StatusNotFound { + t.Fatalf("same-team delete avatar: want 200 or 404, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/server/handlers/persona_groups.go b/server/handlers/persona_groups.go index 0d1eab2..df3acb2 100644 --- a/server/handlers/persona_groups.go +++ b/server/handlers/persona_groups.go @@ -51,7 +51,7 @@ func (h *PersonaGroupHandler) List(c *gin.Context) { for rows.Next() { var g models.PersonaGroup if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil { + &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)); err != nil { continue } groups = append(groups, g) @@ -62,7 +62,7 @@ func (h *PersonaGroupHandler) List(c *gin.Context) { groups[i].Members = h.loadMembers(c, groups[i].ID) } - c.JSON(http.StatusOK, gin.H{"groups": groups}) + c.JSON(http.StatusOK, gin.H{"data": groups}) } // โโ Get โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ @@ -76,7 +76,7 @@ func (h *PersonaGroupHandler) Get(c *gin.Context) { SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at FROM persona_groups WHERE id = $1 AND owner_id = $2 `), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) + &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return @@ -87,6 +87,9 @@ func (h *PersonaGroupHandler) Get(c *gin.Context) { } g.Members = h.loadMembers(c, g.ID) + if g.Members == nil { + g.Members = []models.PersonaGroupMember{} + } c.JSON(http.StatusOK, g) } @@ -119,7 +122,7 @@ func (h *PersonaGroupHandler) Create(c *gin.Context) { SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at FROM persona_groups WHERE id = ? `, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) + &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) } else { err := database.DB.QueryRowContext(c.Request.Context(), ` INSERT INTO persona_groups (name, description, owner_id, scope) @@ -127,7 +130,7 @@ func (h *PersonaGroupHandler) Create(c *gin.Context) { RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at `, strings.TrimSpace(req.Name), req.Description, userID).Scan( &g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) + &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) return diff --git a/server/handlers/personas.go b/server/handlers/personas.go index 164882f..d1b3772 100644 --- a/server/handlers/personas.go +++ b/server/handlers/personas.go @@ -30,7 +30,10 @@ func (h *PersonaHandler) ListUserPersonas(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"personas": personas}) + if personas == nil { + personas = []models.Persona{} + } + c.JSON(http.StatusOK, gin.H{"data": personas}) } func (h *PersonaHandler) CreateUserPersona(c *gin.Context) { @@ -121,7 +124,10 @@ func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"personas": personas}) + if personas == nil { + personas = []models.Persona{} + } + c.JSON(http.StatusOK, gin.H{"data": personas}) } func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) { @@ -147,7 +153,34 @@ func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) { c.JSON(http.StatusCreated, persona) } +// UpdateTeamPersona updates a team-scoped persona. Verifies the persona +// belongs to the team specified in the URL path. +func (h *PersonaHandler) UpdateTeamPersona(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } + id := c.Param("id") + + var patch models.PersonaPatch + if err := c.ShouldBindJSON(&patch); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "persona updated"}) +} + +// DeleteTeamPersona deletes a team-scoped persona. Verifies the persona +// belongs to the team specified in the URL path before deleting. func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } id := c.Param("id") if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { @@ -167,7 +200,10 @@ func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"personas": personas}) + if personas == nil { + personas = []models.Persona{} + } + c.JSON(http.StatusOK, gin.H{"data": personas}) } func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) { @@ -286,6 +322,21 @@ func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": kbs}) } +// GetTeamPersonaKBs returns KBs bound to a persona, verifying team ownership. +func (h *PersonaHandler) GetTeamPersonaKBs(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } + personaID := c.Param("id") + + kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": kbs}) +} + // SetPersonaKBs replaces the knowledge bases bound to a persona. func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) { personaID := c.Param("id") @@ -307,6 +358,30 @@ func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } +// SetTeamPersonaKBs replaces KBs bound to a persona, verifying team ownership. +func (h *PersonaHandler) SetTeamPersonaKBs(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } + personaID := c.Param("id") + + var req struct { + KBIDs []string `json:"kb_ids"` + AutoSearch map[string]bool `json:"auto_search"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + // โโ Persona Tool Grants (v0.25.0) โโโโโโโโโโโ // GetPersonaToolGrants returns the tool names granted to a persona. @@ -318,6 +393,9 @@ func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"}) return } + if grants == nil { + grants = []string{} + } c.JSON(http.StatusOK, gin.H{"data": grants}) } @@ -351,3 +429,141 @@ func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } + +// โโ User-Scoped Persona Avatar (with ownership check) โโโโโ + +// UploadUserPersonaAvatar uploads an avatar for a personal persona, +// verifying the caller owns it. +func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) { + userID := getUserID(c) + personaID := c.Param("id") + + // Verify ownership + existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) + return + } + if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"}) + return + } + + // Delegate to the shared avatar handler + UploadPersonaAvatar(c) +} + +// DeleteUserPersonaAvatar deletes an avatar for a personal persona, +// verifying the caller owns it. +func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) { + userID := getUserID(c) + personaID := c.Param("id") + + // Verify ownership + existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) + return + } + if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"}) + return + } + + // Delegate to the shared avatar handler + DeletePersonaAvatar(c) +} + +// โโ Team-Scoped Helpers โโโโโโโโโโโโโโโโโโโโโ + +// requireTeamPersona loads a persona by ID and verifies it belongs to +// the team in the URL path. Returns the persona on success or writes +// an error response and returns nil. +func (h *PersonaHandler) requireTeamPersona(c *gin.Context) *models.Persona { + teamID := c.Param("teamId") + personaID := c.Param("id") + + existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) + return nil + } + if existing.Scope != models.ScopeTeam || existing.OwnerID == nil || *existing.OwnerID != teamID { + c.JSON(http.StatusForbidden, gin.H{"error": "persona does not belong to this team"}) + return nil + } + return existing +} + +// โโ Team-Scoped Persona Tool Grants โโโโโโโโโ + +// GetTeamPersonaToolGrants returns tool grants for a team persona, +// verifying team ownership. +func (h *PersonaHandler) GetTeamPersonaToolGrants(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } + personaID := c.Param("id") + grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"}) + return + } + if grants == nil { + grants = []string{} + } + c.JSON(http.StatusOK, gin.H{"data": grants}) +} + +// SetTeamPersonaToolGrants replaces tool grants for a team persona, +// verifying team ownership. +func (h *PersonaHandler) SetTeamPersonaToolGrants(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } + personaID := c.Param("id") + + var req struct { + ToolNames []string `json:"tool_names"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + grants := make([]models.Grant, 0, len(req.ToolNames)) + for _, name := range req.ToolNames { + grants = append(grants, models.Grant{ + PersonaID: personaID, + GrantType: models.GrantTypeTool, + GrantRef: name, + }) + } + + if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// โโ Team-Scoped Persona Avatar โโโโโโโโโโโโโโ + +// UploadTeamPersonaAvatar uploads an avatar for a team persona, +// verifying team ownership. +func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } + UploadPersonaAvatar(c) +} + +// DeleteTeamPersonaAvatar deletes an avatar for a team persona, +// verifying team ownership. +func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) { + if p := h.requireTeamPersona(c); p == nil { + return + } + DeletePersonaAvatar(c) +} diff --git a/server/main.go b/server/main.go index 3e4c0c2..298cbc5 100644 --- a/server/main.go +++ b/server/main.go @@ -713,8 +713,8 @@ func main() { protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona) protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona) protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona) - protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar) - protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar) + protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar) + protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar) protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 @@ -898,9 +898,14 @@ func main() { teamPersonas := handlers.NewPersonaHandler(stores) teamScoped.GET("/personas", teamPersonas.ListTeamPersonas) teamScoped.POST("/personas", teamPersonas.CreateTeamPersona) + teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona) teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona) - teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0 - teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0 + teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) // v0.17.0 + teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) // v0.17.0 + teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) // v0.28.0 + teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) // v0.28.0 + teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0 + teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) // v0.28.0 // Team role overrides teamRoles := handlers.NewRolesHandler(stores, roleResolver) diff --git a/server/models/models.go b/server/models/models.go index 2c39731..c7ad465 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -421,7 +421,7 @@ type PersonaGroup struct { OwnerID string `json:"owner_id" db:"owner_id"` Scope string `json:"scope" db:"scope"` TeamID *string `json:"team_id,omitempty" db:"team_id"` - Members []PersonaGroupMember `json:"members,omitempty" db:"-"` + Members []PersonaGroupMember `json:"members" db:"-"` } type PersonaGroupMember struct { diff --git a/server/store/interfaces.go b/server/store/interfaces.go index b5b84ae..38251d2 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -129,7 +129,6 @@ type PersonaStore interface { ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) ListGlobal(ctx context.Context) ([]models.Persona, error) - ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) // user's own // Grants SetGrants(ctx context.Context, personaID string, grants []models.Grant) error diff --git a/server/store/postgres/persona.go b/server/store/postgres/persona.go index 7078486..b48544d 100644 --- a/server/store/postgres/persona.go +++ b/server/store/postgres/persona.go @@ -168,17 +168,6 @@ func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) return scanPersonas(rows) } -func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) { - rows, err := DB.QueryContext(ctx, - fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = $1 ORDER BY name", personaCols), - userID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanPersonas(rows) -} - // โโ Grants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ // SetGrants replaces all grants for a Persona (delete + re-insert). diff --git a/server/store/sqlite/persona.go b/server/store/sqlite/persona.go index 84e4d94..829b7bd 100644 --- a/server/store/sqlite/persona.go +++ b/server/store/sqlite/persona.go @@ -175,17 +175,6 @@ func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) return scanPersonas(rows) } -func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) { - rows, err := DB.QueryContext(ctx, - fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = ? ORDER BY name", personaCols), - userID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanPersonas(rows) -} - // โโ Grants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ // SetGrants replaces all grants for a Persona (delete + re-insert). diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js index 6095cbc..bef584e 100644 --- a/src/js/ui-admin.js +++ b/src/js/ui-admin.js @@ -662,7 +662,7 @@ Object.assign(UI, { if (!quiet) el.innerHTML = '