Changeset 0.28.0.7 (#179)

This commit is contained in:
2026-03-12 19:34:48 +00:00
parent 1a5eb707ba
commit f3f5afd14c
11 changed files with 1189 additions and 89 deletions

View File

@@ -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.

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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).

View File

@@ -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).

View File

@@ -662,7 +662,7 @@ Object.assign(UI, {
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPersonas();
const list = data.personas || [];
const list = data.data || [];
// Ensure form is initialized for dropdown population
ensureAdminPersonaForm();

View File

@@ -508,7 +508,7 @@ Object.assign(UI, {
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListPersonas(teamId);
const personas = resp.personas || [];
const personas = resp.data || [];
el.innerHTML = personas.map(p => {
const icon = p.icon || '';
return `<div class="admin-persona-row" style="padding:6px 0">
@@ -840,7 +840,7 @@ Object.assign(UI, {
try {
const data = await API.listUserPersonas();
// Only show personal personas owned by user
const personas = (data.personas || []).filter(p => p.scope === 'personal');
const personas = (data.data || []).filter(p => p.scope === 'personal');
if (!personas.length) {
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
return;