Bump VERSION to 0.2.8, finalize changelog
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m14s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 27s

Team admin settings audit (pass 1): remove dead BYOK/provider/persona
vestiges from team store, models, policy defaults, workflow stage UI,
and ICD tests. Fix stale chat_only stage mode in frontend to match
backend CHECK constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 16:13:10 +00:00
parent 82430a2cb2
commit d2739aabd2
12 changed files with 51 additions and 102 deletions

View File

@@ -2,6 +2,35 @@
All notable changes to Switchboard Core are documented here.
## v0.2.8 — Team Admin Settings Audit (Pass 1)
### Removed
- **Dead `HasPrivateProviderRequirement`**: Store method checked team settings
for `require_private_providers` (BYOK vestige). Removed from interface,
PostgreSQL, and SQLite implementations. No callers existed.
- **Dead `UserRole` field on `TeamMember`**: Joined `users.role` column
(deprecated in v0.2.0 RBAC migration). Removed from model, `ListMembers`
and `GetMember` queries in both stores. Frontend never consumed it.
- **Dead `allow_team_providers` policy**: Removed from `PolicyDefaults`, both
test seed data blocks, and ICD test assertions. No handler or UI read it.
- **Dead personas in workflow stage UI**: Removed `sw.api.teams.personas()`
call (endpoint doesn't exist), personas state, persona dropdown in
StageForm, and persona badge in stage list.
- **Dead `history_mode` in stage UI**: Removed history mode selector and state
from StageForm. Backend column retained for v0.3.x schema migration.
- **Dead ICD tests**: Removed assertions for `/teams/:teamId/personas`,
`/teams/:teamId/providers`, and `/teams/:teamId/models` — endpoints were
removed in Phase 0 fork.
- **Stale `chat_only` stage mode**: Frontend `STAGE_MODES` updated from
`['chat_only', 'form_only', 'form_chat', 'review']` to
`['form_only', 'form_chat', 'review', 'custom']` matching the backend
CHECK constraint.
- **Stale comments**: Removed references to deleted `team_providers.go`,
`personas.go`, and `apiconfigs.go` files.
---
## v0.2.7 — User Settings Audit
### Changed

View File

@@ -107,8 +107,8 @@ SDK stabilization, and the first rebuilt extension (tasks).
| Step | Status | Description |
|------|--------|-------------|
| Team admin E2E | | Verify team member management, team settings cascade, role assignment (admin/member). Audit for dead code from pre-fork team features. First pass — validates current functionality before v0.3.x adds team roles. |
| User settings team tab | | Verify the Teams section in user settings — team list, join/leave, team-scoped settings. |
| Team admin E2E | | Audited team member management, settings cascade, role assignment. Removed dead code: `HasPrivateProviderRequirement` (BYOK vestige), `UserRole` on `TeamMember` (deprecated role column), `allow_team_providers` policy default, dead personas/providers/models ICD tests. |
| Workflow stage UI cleanup | | Removed dead personas dropdown, `history_mode` selector, stale `chat_only` mode. Updated `STAGE_MODES` to match backend CHECK constraint (`form_only`, `form_chat`, `review`, `custom`). Removed stale comments referencing deleted files. |
### v0.2.9 — Builtin Extension Retirement

View File

@@ -1 +1 @@
0.2.7
0.2.8

View File

@@ -74,24 +74,6 @@
T.assert(Array.isArray(d.data), 'data should be array');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/personas', async function () {
var d = await T.apiGet('/teams/' + teamId + '/personas');
T.assertHasKey(d, 'data', '/team-personas');
T.assert(Array.isArray(d.data), 'team personas should be array');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/providers', async function () {
var d = await T.apiGet('/teams/' + teamId + '/providers');
T.assertHasKey(d, 'data', '/team-providers');
T.assert(Array.isArray(d.data), 'data should be array');
T.assertHasKey(d, 'allow_team_providers', '/team-providers');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/models', async function () {
var d = await T.apiGet('/teams/' + teamId + '/models');
T.assertHasKey(d, 'models', '/team-models');
});
// ── Team Tasks CRUD ──
var teamTaskId = null;

View File

@@ -341,8 +341,7 @@ func TruncateAll(t *testing.T) {
DB.Exec(`
INSERT INTO platform_policies (key, value) VALUES
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
('default_user_active', 'false')
ON CONFLICT (key) DO NOTHING
`)
} else {
@@ -356,8 +355,7 @@ func TruncateAll(t *testing.T) {
DB.Exec(`
INSERT INTO platform_policies (key, value) VALUES
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
('default_user_active', 'false')
ON CONFLICT (key) DO NOTHING
`)
}

View File

@@ -1,7 +1,7 @@
package handlers
// v0.38.1: Extension connection handlers — team scope.
// Methods on TeamHandler, mirrors team_providers.go.
// Extension connection handlers — team scope.
// Methods on TeamHandler.
import (
"net/http"

View File

@@ -78,15 +78,13 @@ type TeamMember struct {
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Username string `json:"username,omitempty"`
UserRole string `json:"user_role,omitempty"`
}
// PLATFORM POLICIES
var PolicyDefaults = map[string]string{
"allow_registration": "true",
"default_user_active": "false",
"allow_team_providers": "true",
"allow_registration": "true",
"default_user_active": "false",
}
// HandleFromName generates a URL-safe @mention handle from a display name.

View File

@@ -170,10 +170,6 @@ type TeamStore interface {
// AddMemberReturningID inserts a team member and returns the row ID.
AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error)
// HasPrivateProviderRequirement checks if a user belongs to any active team
// with the require_private_providers setting enabled.
HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error)
// MergeSettings merges a JSON string into the team's settings column.
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
}

View File

@@ -19,8 +19,7 @@ func SetDB(db *sql.DB) {
}
// ── Dynamic SQL Builder ─────────────────────
// Replaces the copy-pasted addClause/addField pattern
// found in admin.go, personas.go, team_providers.go, apiconfigs.go.
// Replaces the copy-pasted addClause/addField pattern.
// UpdateBuilder constructs a dynamic UPDATE statement.
type UpdateBuilder struct {

View File

@@ -142,7 +142,7 @@ func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role s
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
u.email, COALESCE(u.display_name, ''), u.username
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = $1
@@ -156,7 +156,7 @@ func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.Te
for rows.Next() {
var m models.TeamMember
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
&m.Email, &m.DisplayName, &m.Username)
if err != nil {
return nil, err
}
@@ -169,12 +169,12 @@ func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*mode
var m models.TeamMember
err := DB.QueryRowContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
u.email, COALESCE(u.display_name, ''), u.username
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = $1 AND tm.user_id = $2`, teamID, userID).Scan(
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
&m.Email, &m.DisplayName, &m.Username)
if err != nil {
return nil, err
}
@@ -302,19 +302,6 @@ func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, ro
return id, err
}
func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) {
var has bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = $1
AND t.is_active = true
AND t.settings->>'require_private_providers' = 'true'
)`, userID).Scan(&has)
return has, err
}
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
_, err := DB.ExecContext(ctx,
`UPDATE teams SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`,

View File

@@ -149,7 +149,7 @@ func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role s
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
u.email, COALESCE(u.display_name, ''), u.username
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ?
@@ -163,7 +163,7 @@ func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.Te
for rows.Next() {
var m models.TeamMember
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
&m.Email, &m.DisplayName, &m.Username)
if err != nil {
return nil, err
}
@@ -176,12 +176,12 @@ func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*mode
var m models.TeamMember
err := DB.QueryRowContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
u.email, COALESCE(u.display_name, ''), u.username
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ? AND tm.user_id = ?`, teamID, userID).Scan(
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
&m.Email, &m.DisplayName, &m.Username)
if err != nil {
return nil, err
}
@@ -308,19 +308,6 @@ func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, ro
return id, err
}
func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) {
var has bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = ?
AND t.is_active = 1
AND json_extract(t.settings, '$.require_private_providers') = 'true'
)`, userID).Scan(&has)
return has, err
}
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
_, err := DB.ExecContext(ctx,
`UPDATE teams SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,

View File

@@ -10,8 +10,7 @@ const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const ENTRY_MODES = ['public_link', 'team_only'];
const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review'];
const HISTORY_MODES = ['full', 'summary', 'fresh'];
const STAGE_MODES = ['form_only', 'form_chat', 'review', 'custom'];
const TABS = ['Workflows', 'Assignments', 'Monitor'];
function _timeAgo(ts) {
@@ -143,18 +142,13 @@ function WorkflowsTab({ teamId }) {
function WorkflowEditor({ teamId, workflow, onBack }) {
const [stages, setStages] = useState([]);
const [personas, setPersonas] = useState([]);
const [teams, setTeams] = useState([]);
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
const loadStages = useCallback(async () => {
try {
const [s, p] = await Promise.all([
sw.api.teams.workflowStages(teamId, workflow.id),
sw.api.teams.personas(teamId),
]);
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
setStages(s || []);
setPersonas(p || []);
setTeams(sw.auth?.teams || []);
} catch (e) { sw.toast(e.message, 'error'); }
}, [teamId, workflow.id]);
@@ -259,7 +253,6 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode || '\u2014'}</span>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
<button class="btn-small" onClick=${() => setEditingStage(s.id)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deleteStage(s.id)}>\u00d7</button>
@@ -271,7 +264,6 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
${editingStage && html`
<${StageForm}
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
personas=${personas}
teams=${teams}
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
onCancel=${() => setEditingStage(null)}
@@ -283,12 +275,10 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
// ── Stage Form ──────────────────────────────
function StageForm({ stage, personas, teams, onSave, onCancel }) {
function StageForm({ stage, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
const [mode, setMode] = useState(stage?.stage_mode || 'form_only');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
@@ -296,9 +286,7 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
onSave({
name,
stage_mode: mode,
persona_id: personaId || null,
assignment_team_id: assignTeam || null,
history_mode: historyMode,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
});
@@ -319,13 +307,6 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Persona (LLM)</label>
<select value=${personaId} onChange=${e => setPersonaId(e.target.value)}>
<option value="">\u2014 none \u2014</option>
${personas.map(p => html`<option key=${p.id} value=${p.id}>${p.name}</option>`)}
</select>
</div>
<div class="form-group">
<label>Queue to Team</label>
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
@@ -333,14 +314,6 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>History Mode</label>
<select value=${historyMode} onChange=${e => setHistoryMode(e.target.value)}>
${HISTORY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
</div>
<div class="form-group">
<label>SLA (seconds)</label>
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />