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
|
interact with Personas, not raw provider configs. Admins curate which
|
||||||
Personas are available; team admins create team-scoped Personas.
|
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:
|
Three scopes, three route namespaces, one domain:
|
||||||
|
|
||||||
| Scope | Routes | Who manages |
|
| Scope | Routes | Who manages |
|
||||||
@@ -29,67 +19,78 @@ All three return the same Persona object shape:
|
|||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"name": "Code Reviewer",
|
"name": "Code Reviewer",
|
||||||
|
"handle": "code-reviewer",
|
||||||
"description": "Reviews code for bugs and style",
|
"description": "Reviews code for bugs and style",
|
||||||
"system_prompt": "You are a senior code reviewer...",
|
"icon": "🔍",
|
||||||
"model": "claude-sonnet-4-20250514",
|
"avatar": "data:image/png;base64,...",
|
||||||
|
"base_model_id": "claude-sonnet-4-20250514",
|
||||||
"provider_config_id": "uuid|null",
|
"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",
|
"scope": "personal|team|global",
|
||||||
"owner_id": "uuid|null",
|
"owner_id": "uuid|null",
|
||||||
"is_default": false,
|
"created_by": "uuid",
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"avatar_url": "/api/v1/personas/uuid/avatar?v=123",
|
"is_shared": false,
|
||||||
"inherited": {
|
"memory_enabled": false,
|
||||||
"context_window": 200000,
|
"memory_extraction_prompt": null,
|
||||||
"max_output_tokens": 8192,
|
"grants": [],
|
||||||
"supports_vision": true,
|
"kb_ids": [],
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"created_at": "...",
|
"created_at": "...",
|
||||||
"updated_at": "..."
|
"updated_at": "..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `inherited` block is populated by the backend from the resolved
|
`grants` and `kb_ids` are loaded from junction tables, not stored on the
|
||||||
model capabilities at response time — it is not stored on the persona.
|
persona row. All nullable fields (`provider_config_id`, `owner_id`,
|
||||||
If the underlying model's capabilities change (e.g. provider upgrades
|
`temperature`, `max_tokens`, `thinking_budget`, `top_p`,
|
||||||
context window), the persona inherits the new values automatically.
|
`memory_extraction_prompt`) are omitted from JSON when null.
|
||||||
The frontend uses `inherited` to render capability pills and context
|
|
||||||
size badges identical to the raw model.
|
`handle` is auto-generated from `name` on creation if not provided
|
||||||
|
(e.g. "Code Reviewer" → "code-reviewer"). Used for @mention routing.
|
||||||
|
|
||||||
### Personal Personas
|
### Personal Personas
|
||||||
|
|
||||||
Gated by `allow_user_personas` policy.
|
Gated by `allow_user_personas` policy.
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /personas → { "personas": [...] }
|
GET /personas → { "data": [...] }
|
||||||
POST /personas ← { "name", "description", "system_prompt", "model", ... }
|
POST /personas ← { "name", "base_model_id", ... }
|
||||||
PUT /personas/:id ← partial update
|
PUT /personas/:id ← partial update
|
||||||
DELETE /personas/:id
|
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
|
### Team Personas
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/:teamId/personas → { "personas": [...] }
|
GET /teams/:teamId/personas → { "data": [...] }
|
||||||
POST /teams/:teamId/personas ← same shape
|
POST /teams/:teamId/personas ← same shape
|
||||||
PUT /teams/:teamId/personas/:id
|
PUT /teams/:teamId/personas/:id ← partial update
|
||||||
DELETE /teams/:teamId/personas/:id
|
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
|
### Admin (Global) Personas
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /admin/personas → { "personas": [...] }
|
GET /admin/personas → { "data": [...] }
|
||||||
POST /admin/personas ← same shape
|
POST /admin/personas ← same shape
|
||||||
PUT /admin/personas/:id
|
PUT /admin/personas/:id
|
||||||
DELETE /admin/personas/:id
|
DELETE /admin/personas/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** Admin JWT required (middleware enforced).
|
||||||
|
|
||||||
### Persona KB Bindings
|
### Persona KB Bindings
|
||||||
|
|
||||||
A Persona can have Knowledge Bases bound to it. When a channel uses
|
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
|
context). When `false`, it's available via the `kb_search` tool but
|
||||||
not auto-injected.
|
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
|
### 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
|
DELETE /personas/:id/avatar
|
||||||
POST /admin/personas/:id/avatar
|
POST /admin/personas/:id/avatar ← same
|
||||||
DELETE /admin/personas/:id/avatar
|
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
|
### Persona Tool Grants
|
||||||
|
|
||||||
Control which tools a persona can use during completions. The completion
|
Control which tools a persona can use during completions. The completion
|
||||||
handler applies tool grants as a second-pass allowlist.
|
handler applies tool grants as a second-pass allowlist.
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /personas/:id/tool-grants → { "grants": ["web_search", "kb_search", ...] }
|
GET /personas/:id/tool-grants → { "data": ["web_search", "kb_search", ...] }
|
||||||
PUT /personas/:id/tool-grants ← { "tool_ids": ["web_search", "calculator"] }
|
PUT /personas/:id/tool-grants ← { "tool_names": ["web_search", "calculator"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Admin equivalents:
|
Admin equivalents:
|
||||||
@@ -153,6 +172,13 @@ GET /admin/personas/:id/tool-grants
|
|||||||
PUT /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
|
When a workflow version is published, persona tool grants at that moment
|
||||||
are frozen into the version snapshot.
|
are frozen into the version snapshot.
|
||||||
|
|
||||||
@@ -163,7 +189,7 @@ can be stamped onto new conversations.
|
|||||||
|
|
||||||
```
|
```
|
||||||
GET /persona-groups → { "data": [...] }
|
GET /persona-groups → { "data": [...] }
|
||||||
POST /persona-groups ← { "name", "description", "scope", "team_id" }
|
POST /persona-groups ← { "name", "description" }
|
||||||
GET /persona-groups/:id → group with members
|
GET /persona-groups/:id → group with members
|
||||||
PUT /persona-groups/:id ← partial update
|
PUT /persona-groups/:id ← partial update
|
||||||
DELETE /persona-groups/:id
|
DELETE /persona-groups/:id
|
||||||
@@ -172,12 +198,13 @@ DELETE /persona-groups/:id
|
|||||||
**Members:**
|
**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
|
DELETE /persona-groups/:id/members/:memberId
|
||||||
```
|
```
|
||||||
|
|
||||||
`is_leader`: the leader persona responds when no @mention is present in
|
`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:**
|
**Persona Group Object:**
|
||||||
|
|
||||||
@@ -187,18 +214,30 @@ a group channel. Only one leader per group.
|
|||||||
"name": "Support Team",
|
"name": "Support Team",
|
||||||
"description": "...",
|
"description": "...",
|
||||||
"owner_id": "uuid",
|
"owner_id": "uuid",
|
||||||
"scope": "personal|team|global",
|
"scope": "personal",
|
||||||
"team_id": "uuid|null",
|
"team_id": null,
|
||||||
"members": [
|
"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": "...",
|
"created_at": "...",
|
||||||
"updated_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
|
### Resource Grants
|
||||||
|
|
||||||
See [teams.md](teams.md) §15.3 for the grant system. Personas use 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.
|
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.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/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow)
|
||||||
teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun)
|
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)
|
// Team task viewing for all members (v0.28.0-audit)
|
||||||
@@ -240,8 +253,24 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
personas := NewPersonaHandler(stores)
|
personas := NewPersonaHandler(stores)
|
||||||
protected.GET("/personas", personas.ListUserPersonas)
|
protected.GET("/personas", personas.ListUserPersonas)
|
||||||
protected.POST("/personas", personas.CreateUserPersona)
|
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.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||||
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // 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
|
||||||
notes := NewNoteHandler(stores)
|
notes := NewNoteHandler(stores)
|
||||||
@@ -340,8 +369,14 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
|
admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
|
||||||
admin.GET("/personas", personas.ListAdminPersonas)
|
admin.GET("/personas", personas.ListAdminPersonas)
|
||||||
admin.POST("/personas", personas.CreateAdminPersona)
|
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.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||||
admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // 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)
|
// Admin groups (v0.16.0)
|
||||||
groupAdm := NewGroupHandler(stores)
|
groupAdm := NewGroupHandler(stores)
|
||||||
@@ -2921,7 +2956,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
|||||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||||
var personasResp map[string]interface{}
|
var personasResp map[string]interface{}
|
||||||
decode(w, &personasResp)
|
decode(w, &personasResp)
|
||||||
personas, _ := personasResp["personas"].([]interface{})
|
personas, _ := personasResp["data"].([]interface{})
|
||||||
for _, p := range personas {
|
for _, p := range personas {
|
||||||
pm := p.(map[string]interface{})
|
pm := p.(map[string]interface{})
|
||||||
if pm["id"] == personaID {
|
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())
|
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
decode(w, &personasResp)
|
decode(w, &personasResp)
|
||||||
personas, _ = personasResp["personas"].([]interface{})
|
personas, _ = personasResp["data"].([]interface{})
|
||||||
found := false
|
found := false
|
||||||
for _, p := range personas {
|
for _, p := range personas {
|
||||||
pm := p.(map[string]interface{})
|
pm := p.(map[string]interface{})
|
||||||
@@ -2973,7 +3008,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
|||||||
|
|
||||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||||
decode(w, &personasResp)
|
decode(w, &personasResp)
|
||||||
personas, _ = personasResp["personas"].([]interface{})
|
personas, _ = personasResp["data"].([]interface{})
|
||||||
for _, p := range personas {
|
for _, p := range personas {
|
||||||
pm := p.(map[string]interface{})
|
pm := p.(map[string]interface{})
|
||||||
if pm["id"] == personaID {
|
if pm["id"] == personaID {
|
||||||
@@ -3979,3 +4014,828 @@ func mapKeys(m map[string]interface{}) []string {
|
|||||||
}
|
}
|
||||||
return keys
|
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() {
|
for rows.Next() {
|
||||||
var g models.PersonaGroup
|
var g models.PersonaGroup
|
||||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
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
|
continue
|
||||||
}
|
}
|
||||||
groups = append(groups, g)
|
groups = append(groups, g)
|
||||||
@@ -62,7 +62,7 @@ func (h *PersonaGroupHandler) List(c *gin.Context) {
|
|||||||
groups[i].Members = h.loadMembers(c, groups[i].ID)
|
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 ─────────────────────────────────────────
|
// ── 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
|
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||||
FROM persona_groups WHERE id = $1 AND owner_id = $2
|
FROM persona_groups WHERE id = $1 AND owner_id = $2
|
||||||
`), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
`), 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 {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||||
return
|
return
|
||||||
@@ -87,6 +87,9 @@ func (h *PersonaGroupHandler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g.Members = h.loadMembers(c, g.ID)
|
g.Members = h.loadMembers(c, g.ID)
|
||||||
|
if g.Members == nil {
|
||||||
|
g.Members = []models.PersonaGroupMember{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, g)
|
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
|
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||||
FROM persona_groups WHERE id = ?
|
FROM persona_groups WHERE id = ?
|
||||||
`, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
`, 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 {
|
} else {
|
||||||
err := database.DB.QueryRowContext(c.Request.Context(), `
|
err := database.DB.QueryRowContext(c.Request.Context(), `
|
||||||
INSERT INTO persona_groups (name, description, owner_id, scope)
|
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
|
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||||
`, strings.TrimSpace(req.Name), req.Description, userID).Scan(
|
`, strings.TrimSpace(req.Name), req.Description, userID).Scan(
|
||||||
&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
&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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
|
|||||||
return
|
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) {
|
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
|
||||||
@@ -121,7 +124,10 @@ func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
|
|||||||
return
|
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) {
|
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
|
||||||
@@ -147,7 +153,34 @@ func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, persona)
|
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) {
|
func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) {
|
||||||
|
if p := h.requireTeamPersona(c); p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
|
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
|
||||||
@@ -167,7 +200,10 @@ func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
|
|||||||
return
|
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) {
|
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})
|
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.
|
// SetPersonaKBs replaces the knowledge bases bound to a persona.
|
||||||
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
|
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
|
||||||
personaID := c.Param("id")
|
personaID := c.Param("id")
|
||||||
@@ -307,6 +358,30 @@ func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
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) ───────────
|
// ── Persona Tool Grants (v0.25.0) ───────────
|
||||||
|
|
||||||
// GetPersonaToolGrants returns the tool names granted to a persona.
|
// 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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if grants == nil {
|
||||||
|
grants = []string{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"data": grants})
|
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"})
|
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.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona)
|
||||||
protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona)
|
protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona)
|
||||||
protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona)
|
protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona)
|
||||||
protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
|
protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
|
||||||
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
|
protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
|
||||||
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||||
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // 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.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
|
||||||
@@ -898,9 +898,14 @@ func main() {
|
|||||||
teamPersonas := handlers.NewPersonaHandler(stores)
|
teamPersonas := handlers.NewPersonaHandler(stores)
|
||||||
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
|
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
|
||||||
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
|
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
|
||||||
|
teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
|
||||||
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
|
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
|
||||||
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
|
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) // v0.17.0
|
||||||
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // 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
|
// Team role overrides
|
||||||
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
|
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ type PersonaGroup struct {
|
|||||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||||
Scope string `json:"scope" db:"scope"`
|
Scope string `json:"scope" db:"scope"`
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||||
Members []PersonaGroupMember `json:"members,omitempty" db:"-"`
|
Members []PersonaGroupMember `json:"members" db:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PersonaGroupMember struct {
|
type PersonaGroupMember struct {
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ type PersonaStore interface {
|
|||||||
ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user
|
ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user
|
||||||
ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error)
|
ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error)
|
||||||
ListGlobal(ctx context.Context) ([]models.Persona, error)
|
ListGlobal(ctx context.Context) ([]models.Persona, error)
|
||||||
ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) // user's own
|
|
||||||
|
|
||||||
// Grants
|
// Grants
|
||||||
SetGrants(ctx context.Context, personaID string, grants []models.Grant) error
|
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)
|
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 ──────────────────────────────────
|
// ── Grants ──────────────────────────────────
|
||||||
|
|
||||||
// SetGrants replaces all grants for a Persona (delete + re-insert).
|
// 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)
|
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 ──────────────────────────────────
|
// ── Grants ──────────────────────────────────
|
||||||
|
|
||||||
// SetGrants replaces all grants for a Persona (delete + re-insert).
|
// 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>';
|
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
try {
|
try {
|
||||||
const data = await API.adminListPersonas();
|
const data = await API.adminListPersonas();
|
||||||
const list = data.personas || [];
|
const list = data.data || [];
|
||||||
|
|
||||||
// Ensure form is initialized for dropdown population
|
// Ensure form is initialized for dropdown population
|
||||||
ensureAdminPersonaForm();
|
ensureAdminPersonaForm();
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ Object.assign(UI, {
|
|||||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
try {
|
try {
|
||||||
const resp = await API.teamListPersonas(teamId);
|
const resp = await API.teamListPersonas(teamId);
|
||||||
const personas = resp.personas || [];
|
const personas = resp.data || [];
|
||||||
el.innerHTML = personas.map(p => {
|
el.innerHTML = personas.map(p => {
|
||||||
const icon = p.icon || '';
|
const icon = p.icon || '';
|
||||||
return `<div class="admin-persona-row" style="padding:6px 0">
|
return `<div class="admin-persona-row" style="padding:6px 0">
|
||||||
@@ -840,7 +840,7 @@ Object.assign(UI, {
|
|||||||
try {
|
try {
|
||||||
const data = await API.listUserPersonas();
|
const data = await API.listUserPersonas();
|
||||||
// Only show personal personas owned by user
|
// 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) {
|
if (!personas.length) {
|
||||||
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
|
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user