M2: admin console over ssh admin@ (users, sessions, moderation, plugins)

A privileged operator console reached as `ssh admin@host`, gated by route
plus the $AGENTBBS_ADMINS allowlist (admin status is operator-granted only,
never self-assigned in-band). It is a self-contained Bubble Tea model, not a
hub plugin, so it never appears in the public menu.

Sections (PRD §6):
  - Users & members: list accounts; b = suspend/ban (operators protected).
    Banned accounts are blocked at the hub and pod@ routes.
  - Sessions & pods: live in-memory session registry; k = disconnect.
  - Moderation & audit: admin action log + agent@ transcripts (tab to switch).
  - Config & plugins: runtime snapshot; space = enable/disable a plugin
    (persisted; filtered from the hub on next sign-in).

Every privileged action is written to a new admin_actions audit table.

store: + banned column, admin_actions and plugin_state tables, and the
backing methods (ListUsers/SetBanned/RecentSessions/LogAdminAction/
RecentAdminActions/RecentChatsAll/DisabledPlugins/SetPluginDisabled), with
unit tests. auth: admin allowlist helpers + tests. Docs in docs/admin.md;
README M2 flipped to done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 09:34:50 +00:00
parent ae8b1c25f4
commit 678a472ae1
9 changed files with 1174 additions and 5 deletions

450
internal/admin/admin.go Normal file
View file

@ -0,0 +1,450 @@
// Package admin is the privileged admin console (PRD §6), reached over SSH as
// `ssh admin@host` by an account in the operator allowlist ($AGENTBBS_ADMINS).
//
// It is a self-contained Bubble Tea model (not a hub plugin) so it never shows
// up in the public menu: the route gates access, the model drives the console.
// Four sections cover the M2 scope — users, live sessions, moderation/audit,
// and config/plugins — over the shared store plus a live-session registry.
package admin
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/store"
)
// Live is one connected SSH session, as seen by the registry.
type Live struct {
ID int64
User string
Remote string
Route string
Start time.Time
}
// LiveSessions is the registry of currently-connected sessions the console can
// list and disconnect. main wires the concrete implementation.
type LiveSessions interface {
List() []Live
Kill(id int64) bool
}
// PluginInfo identifies a registered hub plugin for the config screen.
type PluginInfo struct{ ID, Title string }
// Env is the read-only runtime snapshot shown on the config screen.
type Env struct {
Host string
Sandbox string
PodsEngine string // "" when pods are disabled on this host
MailConfigured bool
Admins []string
Plugins []PluginInfo
}
type screen int
const (
screenMenu screen = iota
screenUsers
screenSessions
screenAudit
screenConfig
)
var menuItems = []struct {
screen screen
label string
desc string
}{
{screenUsers, "Users & members", "List accounts, suspend/ban, inspect"},
{screenSessions, "Sessions & pods", "Live connections — disconnect abusers"},
{screenAudit, "Moderation & audit", "Admin action log + agent@ transcripts"},
{screenConfig, "Config & plugins", "Runtime config; enable/disable plugins"},
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
frameStyle = lipgloss.NewStyle().Padding(1, 2)
)
// Model is the admin console.
type Model struct {
admin auth.User
st store.Store
live LiveSessions
selfLive int64 // the admin's own live-session id, never killed
env Env
screen screen
cursor int
note string
width int
height int
// loaded per-screen data
users []store.User
sessions []Live
actions []store.AdminAction
chats []store.ChatRow
disabled map[string]bool
auditTab int // 0 = admin actions, 1 = agent@ chats
}
// New builds the admin console for one session.
func New(admin auth.User, st store.Store, live LiveSessions, selfLive int64, env Env) Model {
return Model{admin: admin, st: st, live: live, selfLive: selfLive, env: env, disabled: map[string]bool{}}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) log(action, target, detail string) {
_ = m.st.LogAdminAction(m.admin.Name, action, target, detail)
}
// load refreshes the data backing the current screen.
func (m *Model) load() {
m.cursor = 0
switch m.screen {
case screenUsers:
m.users, _ = m.st.ListUsers(200)
case screenSessions:
m.sessions = m.live.List()
case screenAudit:
m.actions, _ = m.st.RecentAdminActions(100)
m.chats, _ = m.st.RecentChatsAll(100)
case screenConfig:
m.disabled, _ = m.st.DisabledPlugins()
}
}
// rowCount is the number of selectable rows on the active screen.
func (m Model) rowCount() int {
switch m.screen {
case screenUsers:
return len(m.users)
case screenSessions:
return len(m.sessions)
case screenConfig:
return len(m.env.Plugins)
default:
return 0
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
m.note = ""
switch msg.String() {
case "ctrl+c", "Q":
return m, tea.Quit
}
if m.screen == screenMenu {
return m.updateMenu(msg)
}
return m.updateScreen(msg)
}
return m, nil
}
func (m Model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(menuItems)-1 {
m.cursor++
}
case "enter", "right", "l":
m.screen = menuItems[m.cursor].screen
m.load()
}
return m, nil
}
func (m Model) updateScreen(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc", "left", "h", "backspace":
m.screen = screenMenu
m.cursor = 0
return m, nil
case "r":
m.load()
m.note = "refreshed"
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
return m, nil
case "down", "j":
if m.cursor < m.rowCount()-1 {
m.cursor++
}
return m, nil
}
switch m.screen {
case screenUsers:
return m.updateUsers(msg)
case screenSessions:
return m.updateSessions(msg)
case screenAudit:
return m.updateAudit(msg)
case screenConfig:
return m.updateConfig(msg)
}
return m, nil
}
func (m Model) updateUsers(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() != "b" || m.cursor >= len(m.users) {
return m, nil
}
u := m.users[m.cursor]
if auth.IsAdmin(u.Name) {
m.note = warnStyle.Render("refusing to ban an operator (" + u.Name + ")")
return m, nil
}
ban := !u.Banned
if err := m.st.SetBanned(u.ID, ban); err != nil {
m.note = warnStyle.Render("ban failed: " + err.Error())
return m, nil
}
action := "unban"
if ban {
action = "ban"
}
m.log(action, u.Name, "")
m.users[m.cursor].Banned = ban
m.note = okStyle.Render(action + "ned " + u.Name)
return m, nil
}
func (m Model) updateSessions(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() != "k" || m.cursor >= len(m.sessions) {
return m, nil
}
s := m.sessions[m.cursor]
if s.ID == m.selfLive {
m.note = warnStyle.Render("that's your own session")
return m, nil
}
if m.live.Kill(s.ID) {
m.log("kill-session", s.User, s.Remote+" "+s.Route)
m.note = okStyle.Render("disconnected " + s.User + " (" + s.Route + ")")
} else {
m.note = warnStyle.Render("session already gone")
}
m.sessions = m.live.List()
if m.cursor >= len(m.sessions) && m.cursor > 0 {
m.cursor--
}
return m, nil
}
func (m Model) updateAudit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "tab" {
m.auditTab = 1 - m.auditTab
}
return m, nil
}
func (m Model) updateConfig(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if (msg.String() != " " && msg.String() != "enter") || m.cursor >= len(m.env.Plugins) {
return m, nil
}
p := m.env.Plugins[m.cursor]
disable := !m.disabled[p.ID]
if err := m.st.SetPluginDisabled(p.ID, disable); err != nil {
m.note = warnStyle.Render("toggle failed: " + err.Error())
return m, nil
}
m.disabled[p.ID] = disable
action := "enable-plugin"
state := "enabled"
if disable {
action, state = "disable-plugin", "disabled"
}
m.log(action, p.ID, "")
m.note = okStyle.Render(p.Title + " " + state + " (takes effect on next sign-in)")
return m, nil
}
func (m Model) View() string {
var body, help string
switch m.screen {
case screenMenu:
body, help = m.viewMenu()
case screenUsers:
body, help = m.viewUsers()
case screenSessions:
body, help = m.viewSessions()
case screenAudit:
body, help = m.viewAudit()
case screenConfig:
body, help = m.viewConfig()
}
header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name)
out := header + "\n\n" + body + "\n" + dimStyle.Render(help)
if m.note != "" {
out += "\n" + m.note
}
return frameStyle.Render(out)
}
func (m Model) viewMenu() (string, string) {
var b strings.Builder
for i, it := range menuItems {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
fmt.Fprintf(&b, "%s%s\n %s\n", cur, it.label, dimStyle.Render(it.desc))
}
return b.String(), "↑/↓ move · enter open · q quit"
}
func (m Model) viewUsers() (string, string) {
var b strings.Builder
b.WriteString(headStyle.Render(fmt.Sprintf("Users (%d)", len(m.users))) + "\n\n")
if len(m.users) == 0 {
b.WriteString(dimStyle.Render(" (no accounts yet)\n"))
}
for i, u := range m.users {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
flags := []string{u.Kind}
if u.Premium {
flags = append(flags, "premium")
}
if u.EmailVerified {
flags = append(flags, "verified")
}
name := u.Name
if u.Banned {
name = warnStyle.Render(name + " [BANNED]")
}
fmt.Fprintf(&b, "%s%-20s %s\n", cur, name, dimStyle.Render(strings.Join(flags, " · ")))
}
return b.String(), "↑/↓ move · b ban/unban · r refresh · esc back"
}
func (m Model) viewSessions() (string, string) {
var b strings.Builder
b.WriteString(headStyle.Render(fmt.Sprintf("Live sessions (%d)", len(m.sessions))) + "\n\n")
if len(m.sessions) == 0 {
b.WriteString(dimStyle.Render(" (nobody connected)\n"))
}
for i, s := range m.sessions {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
who := s.User
if s.ID == m.selfLive {
who += " (you)"
}
age := time.Since(s.Start).Round(time.Second)
fmt.Fprintf(&b, "%s%-18s %-8s %-16s %s\n", cur, who, s.Route, s.Remote, dimStyle.Render(age.String()))
}
return b.String(), "↑/↓ move · k disconnect · r refresh · esc back"
}
func (m Model) viewAudit() (string, string) {
var b strings.Builder
tabs := "[ admin actions ] agent@ chats"
if m.auditTab == 1 {
tabs = " admin actions [ agent@ chats ]"
}
b.WriteString(headStyle.Render("Moderation & audit") + " " + dimStyle.Render(tabs) + "\n\n")
if m.auditTab == 0 {
if len(m.actions) == 0 {
b.WriteString(dimStyle.Render(" (no admin actions logged yet)\n"))
}
for _, a := range m.actions {
line := fmt.Sprintf(" %s %s %s", a.At.Local().Format("01-02 15:04"), a.Admin, a.Action)
if a.Target != "" {
line += " → " + a.Target
}
if a.Detail != "" {
line += " " + dimStyle.Render(a.Detail)
}
b.WriteString(line + "\n")
}
} else {
if len(m.chats) == 0 {
b.WriteString(dimStyle.Render(" (no agent@ messages yet)\n"))
}
for _, c := range m.chats {
text := strings.ReplaceAll(c.Text, "\n", " ")
if len(text) > 60 {
text = text[:60] + "…"
}
fmt.Fprintf(&b, " %s %-12s %-5s %s\n",
c.At.Local().Format("01-02 15:04"), c.Username, c.Role, dimStyle.Render(text))
}
}
return b.String(), "tab switch view · r refresh · esc back · (ban from Users)"
}
func (m Model) viewConfig() (string, string) {
var b strings.Builder
b.WriteString(headStyle.Render("Runtime config") + "\n")
pods := m.env.PodsEngine
if pods == "" {
pods = warnStyle.Render("disabled")
}
mail := "configured"
if !m.env.MailConfigured {
mail = warnStyle.Render("not configured")
}
admins := strings.Join(m.env.Admins, ", ")
if admins == "" {
admins = warnStyle.Render("(none — set AGENTBBS_ADMINS)")
}
fmt.Fprintf(&b, " host %s\n", m.env.Host)
fmt.Fprintf(&b, " sandbox %s\n", m.env.Sandbox)
fmt.Fprintf(&b, " pods %s\n", pods)
fmt.Fprintf(&b, " mail %s\n", mail)
fmt.Fprintf(&b, " admins %s\n", admins)
b.WriteString("\n" + headStyle.Render("Plugins") + "\n\n")
for i, p := range m.env.Plugins {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
state := okStyle.Render("on ")
if m.disabled[p.ID] {
state = warnStyle.Render("off")
}
fmt.Fprintf(&b, "%s[%s] %-12s %s\n", cur, state, p.ID, dimStyle.Render(p.Title))
}
if len(m.env.Plugins) == 0 {
b.WriteString(dimStyle.Render(" (no plugins registered)\n"))
}
return b.String(), "↑/↓ move · space toggle · esc back"
}

View file

@ -2,6 +2,7 @@
package auth
import (
"os"
"strings"
"github.com/charmbracelet/ssh"
@ -40,6 +41,11 @@ var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
// list/add/remove the domains pointed at a member's homepage.
var DomainNames = map[string]bool{"domain": true, "domains": true}
// AdminNames are usernames that route to the privileged admin console (PRD §6).
// The route only opens for accounts whose name is in the operator allowlist
// (see IsAdmin); the name itself confers nothing.
var AdminNames = map[string]bool{"admin": true, "sysop": true}
// IsGuestName reports whether the SSH username requests anonymous hub access.
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
@ -52,6 +58,25 @@ func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
// IsDomainName reports whether the SSH username requests the custom-domain flow.
func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
// IsAdminName reports whether the SSH username requests the admin console.
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
// Admins returns the operator-configured admin allowlist: the lowercased,
// comma/space-separated account names in $AGENTBBS_ADMINS. Admin status can
// only be granted by the operator (via env), never self-assigned in-band.
func Admins() map[string]bool {
out := map[string]bool{}
for _, f := range strings.FieldsFunc(os.Getenv("AGENTBBS_ADMINS"), func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n'
}) {
out[strings.ToLower(f)] = true
}
return out
}
// IsAdmin reports whether the account name is in the operator allowlist.
func IsAdmin(name string) bool { return Admins()[strings.ToLower(name)] }
// KindFor infers the identity kind from a (non-guest) username.
// Usernames prefixed "agent-" are automated clients (PRD §3).
func KindFor(username string) Kind {

View file

@ -0,0 +1,42 @@
package auth
import "testing"
func TestIsAdminName(t *testing.T) {
for _, name := range []string{"admin", "ADMIN", "sysop"} {
if !IsAdminName(name) {
t.Errorf("IsAdminName(%q) = false, want true", name)
}
}
for _, name := range []string{"bbs", "pod", "anthony", ""} {
if IsAdminName(name) {
t.Errorf("IsAdminName(%q) = true, want false", name)
}
}
}
func TestAdminsAllowlist(t *testing.T) {
t.Setenv("AGENTBBS_ADMINS", "anthony, Root ops")
admins := Admins()
for _, want := range []string{"anthony", "root", "ops"} {
if !admins[want] {
t.Errorf("expected %q in allowlist, got %v", want, admins)
}
}
if !IsAdmin("ANTHONY") {
t.Error("IsAdmin should be case-insensitive")
}
if IsAdmin("eve") {
t.Error("eve must not be an admin")
}
}
func TestAdminsEmpty(t *testing.T) {
t.Setenv("AGENTBBS_ADMINS", "")
if len(Admins()) != 0 {
t.Error("empty env should yield no admins")
}
if IsAdmin("anyone") {
t.Error("nobody is admin when allowlist is empty")
}
}

View file

@ -20,23 +20,25 @@ type User struct {
EmailVerified bool
Premium bool // paid the one-time lifetime membership
PremiumPayID string // CoinPay payment id of the pending/settled premium charge
Banned bool // suspended by an admin (blocked at login)
CreatedAt time.Time
}
// userCols is the column list (in struct order) for every user SELECT, kept in
// sync with scanUser.
const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, created_at`
const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, banned, created_at`
// scanUser reads one user row selected with userCols.
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
var u User
var verified, premium int
var verified, premium, banned int
var created string
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &created); err != nil {
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &banned, &created); err != nil {
return User{}, err
}
u.EmailVerified = verified != 0
u.Premium = premium != 0
u.Banned = banned != 0
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil
}
@ -105,9 +107,58 @@ type Store interface {
DomainsForUser(username string) ([]string, error)
AllDomains() ([]DomainMap, error)
// Admin console (PRD §6). All read-only listings are newest-first.
// ListUsers returns up to limit accounts, most recently created first.
ListUsers(limit int) ([]User, error)
// SetBanned suspends (or restores) an account; banned accounts are blocked
// at login by the SSH routes.
SetBanned(userID int64, banned bool) error
// RecentSessions returns the last n session rows (the audit trail).
RecentSessions(n int) ([]SessionRow, error)
// LogAdminAction records one privileged action for the audit log.
LogAdminAction(admin, action, target, detail string) error
// RecentAdminActions returns the last n logged admin actions.
RecentAdminActions(n int) ([]AdminAction, error)
// RecentChatsAll returns the last n agent@ messages across all users, for
// moderation review.
RecentChatsAll(n int) ([]ChatRow, error)
// DisabledPlugins reports the set of plugin IDs currently switched off.
DisabledPlugins() (map[string]bool, error)
// SetPluginDisabled enables or disables a plugin by ID. Idempotent.
SetPluginDisabled(id string, disabled bool) error
Close() error
}
// SessionRow is one connection record from the audit trail.
type SessionRow struct {
ID int64
Username string
Remote string
Route string
Started time.Time
Ended time.Time
EndedValid bool // false while the session is still open
}
// AdminAction is one entry in the admin audit log.
type AdminAction struct {
Admin string
Action string
Target string
Detail string
At time.Time
}
// ChatRow is one agent@ message with its author, for moderation review.
type ChatRow struct {
Username string
Role string
Text string
At time.Time
}
// ChatMessage is one line of an agent@ conversation.
type ChatMessage struct {
Role string // "user" or "agent"
@ -159,6 +210,7 @@ func migrate(db *sql.DB) error {
{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
{"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"},
{"premium_pay_id", "premium_pay_id TEXT NOT NULL DEFAULT ''"},
{"banned", "banned INTEGER NOT NULL DEFAULT 0"},
})
}
@ -239,6 +291,20 @@ CREATE TABLE IF NOT EXISTS domains (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(username);
CREATE TABLE IF NOT EXISTS admin_actions (
id INTEGER PRIMARY KEY,
admin TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_admin_actions_id ON admin_actions(id DESC);
CREATE TABLE IF NOT EXISTS plugin_state (
id TEXT PRIMARY KEY,
disabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
`
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
@ -523,4 +589,146 @@ func (s *sqliteStore) AllDomains() ([]DomainMap, error) {
return out, rows.Err()
}
func (s *sqliteStore) ListUsers(limit int) ([]User, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.db.Query(`SELECT `+userCols+` FROM users ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *sqliteStore) SetBanned(userID int64, banned bool) error {
b := 0
if banned {
b = 1
}
_, err := s.db.Exec(`UPDATE users SET banned = ? WHERE id = ?`, b, userID)
return err
}
func (s *sqliteStore) RecentSessions(n int) ([]SessionRow, error) {
if n <= 0 {
n = 50
}
rows, err := s.db.Query(`
SELECT id, username, remote_addr, route, started_at, ended_at
FROM sessions ORDER BY id DESC LIMIT ?`, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SessionRow
for rows.Next() {
var r SessionRow
var started string
var ended sql.NullString
if err := rows.Scan(&r.ID, &r.Username, &r.Remote, &r.Route, &started, &ended); err != nil {
return nil, err
}
r.Started, _ = time.Parse(time.RFC3339, started)
if ended.Valid && ended.String != "" {
r.Ended, _ = time.Parse(time.RFC3339, ended.String)
r.EndedValid = true
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *sqliteStore) LogAdminAction(admin, action, target, detail string) error {
_, err := s.db.Exec(`INSERT INTO admin_actions (admin, action, target, detail) VALUES (?,?,?,?)`,
admin, action, target, detail)
return err
}
func (s *sqliteStore) RecentAdminActions(n int) ([]AdminAction, error) {
if n <= 0 {
n = 50
}
rows, err := s.db.Query(`
SELECT admin, action, target, detail, created_at
FROM admin_actions ORDER BY id DESC LIMIT ?`, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AdminAction
for rows.Next() {
var a AdminAction
var at string
if err := rows.Scan(&a.Admin, &a.Action, &a.Target, &a.Detail, &at); err != nil {
return nil, err
}
a.At, _ = time.Parse(time.RFC3339, at)
out = append(out, a)
}
return out, rows.Err()
}
func (s *sqliteStore) RecentChatsAll(n int) ([]ChatRow, error) {
if n <= 0 {
n = 50
}
rows, err := s.db.Query(`
SELECT username, role, text, created_at
FROM chat_messages ORDER BY id DESC LIMIT ?`, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatRow
for rows.Next() {
var c ChatRow
var at string
if err := rows.Scan(&c.Username, &c.Role, &c.Text, &at); err != nil {
return nil, err
}
c.At, _ = time.Parse(time.RFC3339, at)
out = append(out, c)
}
return out, rows.Err()
}
func (s *sqliteStore) DisabledPlugins() (map[string]bool, error) {
rows, err := s.db.Query(`SELECT id FROM plugin_state WHERE disabled = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = true
}
return out, rows.Err()
}
func (s *sqliteStore) SetPluginDisabled(id string, disabled bool) error {
d := 0
if disabled {
d = 1
}
_, err := s.db.Exec(`
INSERT INTO plugin_state (id, disabled) VALUES (?,?)
ON CONFLICT(id) DO UPDATE SET
disabled = excluded.disabled,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, id, d)
return err
}
func (s *sqliteStore) Close() error { return s.db.Close() }

View file

@ -0,0 +1,166 @@
package store
import (
"path/filepath"
"testing"
)
func openTest(t *testing.T) Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestBanAndListUsers(t *testing.T) {
st := openTest(t)
a, _ := st.EnsureUser("alice", "member", "SHA256:aaa")
if a.Banned {
t.Fatal("new user must not be banned")
}
_, _ = st.EnsureUser("bob", "member", "SHA256:bbb")
users, err := st.ListUsers(10)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(users) != 2 {
t.Fatalf("want 2 users, got %d", len(users))
}
// Newest-first ordering.
if users[0].Name != "bob" {
t.Fatalf("want bob first, got %s", users[0].Name)
}
if err := st.SetBanned(a.ID, true); err != nil {
t.Fatalf("ban: %v", err)
}
got, _, _ := st.UserByFingerprint("SHA256:aaa")
if !got.Banned {
t.Fatal("alice should be banned")
}
if err := st.SetBanned(a.ID, false); err != nil {
t.Fatalf("unban: %v", err)
}
got, _, _ = st.UserByFingerprint("SHA256:aaa")
if got.Banned {
t.Fatal("alice should be unbanned")
}
}
func TestAdminAuditLog(t *testing.T) {
st := openTest(t)
if acts, _ := st.RecentAdminActions(10); len(acts) != 0 {
t.Fatalf("expected empty log, got %d", len(acts))
}
if err := st.LogAdminAction("anthony", "ban", "spammer", "abuse"); err != nil {
t.Fatalf("log: %v", err)
}
_ = st.LogAdminAction("anthony", "disable-plugin", "arcade", "")
acts, err := st.RecentAdminActions(10)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(acts) != 2 {
t.Fatalf("want 2 actions, got %d", len(acts))
}
// Newest-first.
if acts[0].Action != "disable-plugin" || acts[0].Target != "arcade" {
t.Fatalf("unexpected first action: %+v", acts[0])
}
if acts[1].Admin != "anthony" || acts[1].Detail != "abuse" {
t.Fatalf("unexpected second action: %+v", acts[1])
}
}
func TestRecentSessions(t *testing.T) {
st := openTest(t)
u, _ := st.EnsureUser("carol", "member", "SHA256:ccc")
id, err := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub")
if err != nil {
t.Fatalf("record: %v", err)
}
_, _ = st.RecordSession(0, "bbs", "5.6.7.8", "hub")
rows, err := st.RecentSessions(10)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(rows) != 2 {
t.Fatalf("want 2 sessions, got %d", len(rows))
}
// The first recorded session is still open until ended.
for _, r := range rows {
if r.ID == id && r.EndedValid {
t.Fatal("open session should not have an end time")
}
}
if err := st.EndSession(id); err != nil {
t.Fatalf("end: %v", err)
}
rows, _ = st.RecentSessions(10)
var found bool
for _, r := range rows {
if r.ID == id {
found = true
if !r.EndedValid {
t.Fatal("ended session should have an end time")
}
}
}
if !found {
t.Fatal("ended session missing from recent list")
}
}
func TestPluginState(t *testing.T) {
st := openTest(t)
if d, _ := st.DisabledPlugins(); len(d) != 0 {
t.Fatalf("expected nothing disabled, got %v", d)
}
if err := st.SetPluginDisabled("arcade", true); err != nil {
t.Fatalf("disable: %v", err)
}
d, err := st.DisabledPlugins()
if err != nil {
t.Fatalf("list: %v", err)
}
if !d["arcade"] {
t.Fatal("arcade should be disabled")
}
// Idempotent re-enable.
if err := st.SetPluginDisabled("arcade", false); err != nil {
t.Fatalf("enable: %v", err)
}
if d, _ := st.DisabledPlugins(); d["arcade"] {
t.Fatal("arcade should be enabled again")
}
}
func TestRecentChatsAll(t *testing.T) {
st := openTest(t)
u, _ := st.EnsureUser("dave", "member", "SHA256:ddd")
_ = st.AddChat(u.ID, "dave", "user", "hello there")
_ = st.AddChat(u.ID, "dave", "agent", "hi dave")
chats, err := st.RecentChatsAll(10)
if err != nil {
t.Fatalf("recent chats: %v", err)
}
if len(chats) != 2 {
t.Fatalf("want 2 chats, got %d", len(chats))
}
// Newest-first.
if chats[0].Role != "agent" || chats[0].Username != "dave" {
t.Fatalf("unexpected first chat: %+v", chats[0])
}
}