Changeset 0.28.0.7 (#179)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user