mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-15 07:17:29 +00:00
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:
parent
ae8b1c25f4
commit
678a472ae1
9 changed files with 1174 additions and 5 deletions
|
|
@ -31,7 +31,7 @@ plugins around one shared account system; the full product plan is in
|
||||||
| Pods (rootless containers, free for verified members) | ✅ |
|
| Pods (rootless containers, free for verified members) | ✅ |
|
||||||
| Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) | ✅ |
|
| Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) | ✅ |
|
||||||
| `agent@` chat (configurable agent backend) + finger | ✅ |
|
| `agent@` chat (configurable agent backend) + finger | ✅ |
|
||||||
| M2 — admin console | ⬜ |
|
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ |
|
||||||
| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | ⬜ |
|
| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | ⬜ |
|
||||||
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
|
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
|
||||||
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
|
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
|
||||||
|
|
@ -53,6 +53,7 @@ Configuration (env):
|
||||||
| `AGENTBBS_DATA` | `./data` | SQLite db, host key, per-user dirs |
|
| `AGENTBBS_DATA` | `./data` | SQLite db, host key, per-user dirs |
|
||||||
| `AGENTBBS_ASSETS` | `./assets` | doom binary + wads |
|
| `AGENTBBS_ASSETS` | `./assets` | doom binary + wads |
|
||||||
| `AGENTBBS_HOST` | `bbs.profullstack.com` | hostname shown in messages |
|
| `AGENTBBS_HOST` | `bbs.profullstack.com` | hostname shown in messages |
|
||||||
|
| `AGENTBBS_ADMINS` | unset | operator account names for `admin@` (comma/space-separated) — see [docs/admin.md](docs/admin.md) |
|
||||||
| `AGENTBBS_SANDBOX` | `auto` | `bwrap` / `prlimit` / `none` |
|
| `AGENTBBS_SANDBOX` | `auto` | `bwrap` / `prlimit` / `none` |
|
||||||
| `AGENTBBS_POD_IMAGE` | `debian:stable-slim` | pod base image |
|
| `AGENTBBS_POD_IMAGE` | `debian:stable-slim` | pod base image |
|
||||||
| `AGENTBBS_POD_MEM` / `AGENTBBS_POD_CPUS` | `512m` / `1` | pod caps |
|
| `AGENTBBS_POD_MEM` / `AGENTBBS_POD_CPUS` | `512m` / `1` | pod caps |
|
||||||
|
|
|
||||||
179
cmd/agentbbs/admin.go
Normal file
179
cmd/agentbbs/admin.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/ssh"
|
||||||
|
"github.com/charmbracelet/wish"
|
||||||
|
|
||||||
|
"github.com/profullstack/agentbbs/internal/admin"
|
||||||
|
"github.com/profullstack/agentbbs/internal/auth"
|
||||||
|
"github.com/profullstack/agentbbs/internal/calls"
|
||||||
|
"github.com/profullstack/agentbbs/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// liveReg is the in-memory registry of currently-connected SSH sessions. The
|
||||||
|
// DB sessions table is the historical audit trail; this is the live view the
|
||||||
|
// admin console lists and can disconnect (PRD §6 "terminate live sessions").
|
||||||
|
type liveReg struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
next int64
|
||||||
|
m map[int64]liveEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type liveEntry struct {
|
||||||
|
s ssh.Session
|
||||||
|
user string
|
||||||
|
route string
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLiveReg() *liveReg { return &liveReg{m: map[int64]liveEntry{}} }
|
||||||
|
|
||||||
|
func (r *liveReg) add(s ssh.Session) int64 {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.next++
|
||||||
|
id := r.next
|
||||||
|
r.m[id] = liveEntry{s: s, user: s.User(), route: routeLabel(s.User()), start: time.Now()}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *liveReg) remove(id int64) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.m, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// idFor returns the registry id of an active session, or 0 if absent.
|
||||||
|
func (r *liveReg) idFor(s ssh.Session) int64 {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for id, e := range r.m {
|
||||||
|
if e.s == s {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements admin.LiveSessions, newest connection first.
|
||||||
|
func (r *liveReg) List() []admin.Live {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]admin.Live, 0, len(r.m))
|
||||||
|
for id, e := range r.m {
|
||||||
|
out = append(out, admin.Live{ID: id, User: e.user, Remote: remoteIP(e.s), Route: e.route, Start: e.start})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill closes a live session by id. Returns false if it is already gone.
|
||||||
|
func (r *liveReg) Kill(id int64) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
e, ok := r.m[id]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = e.s.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// track registers every connection in the live registry for its lifetime.
|
||||||
|
func (a *app) track() wish.Middleware {
|
||||||
|
return func(next ssh.Handler) ssh.Handler {
|
||||||
|
return func(s ssh.Session) {
|
||||||
|
id := a.live.add(s)
|
||||||
|
defer a.live.remove(id)
|
||||||
|
next(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeLabel classifies an SSH username into the route it dispatches to, for
|
||||||
|
// display in the admin sessions view.
|
||||||
|
func routeLabel(user string) string {
|
||||||
|
user = strings.ToLower(user)
|
||||||
|
switch {
|
||||||
|
case auth.IsJoinName(user):
|
||||||
|
return "join"
|
||||||
|
case auth.IsDomainName(user):
|
||||||
|
return "domain"
|
||||||
|
case auth.IsPodName(user):
|
||||||
|
return "pod"
|
||||||
|
case auth.IsAdminName(user):
|
||||||
|
return "admin"
|
||||||
|
case user == "agent":
|
||||||
|
return "agent"
|
||||||
|
}
|
||||||
|
if _, isVideo := calls.RouteCode(user); isVideo {
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
return "hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminTeaHandler builds the admin console for an authorized operator. It
|
||||||
|
// re-resolves identity here (not just at the route) so a direct invocation is
|
||||||
|
// still safe: non-admins get a one-line notice and disconnect.
|
||||||
|
func (a *app) adminTeaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
|
fp := auth.Fingerprint(s.PublicKey())
|
||||||
|
var name string
|
||||||
|
if fp != "" {
|
||||||
|
if u, found, _ := a.st.UserByFingerprint(fp); found {
|
||||||
|
name = u.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" || !auth.IsAdmin(name) {
|
||||||
|
wish.Println(s, "admin@ is restricted to operators.")
|
||||||
|
_ = s.Exit(1)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
u := auth.User{Name: name, Kind: auth.Member, PubKeyFP: fp}
|
||||||
|
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "admin")
|
||||||
|
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
|
||||||
|
|
||||||
|
env := admin.Env{
|
||||||
|
Host: a.host,
|
||||||
|
Sandbox: string(a.sandbox.Mode()),
|
||||||
|
MailConfigured: a.mail.Configured(),
|
||||||
|
Admins: sortedKeys(auth.Admins()),
|
||||||
|
}
|
||||||
|
if a.pods != nil {
|
||||||
|
env.PodsEngine = a.pods.Engine()
|
||||||
|
}
|
||||||
|
for _, p := range a.registry {
|
||||||
|
env.Plugins = append(env.Plugins, admin.PluginInfo{ID: p.ID(), Title: p.Title()})
|
||||||
|
}
|
||||||
|
m := admin.New(u, a.st, a.live, a.live.idFor(s), env)
|
||||||
|
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabledPlugins is a.registry minus any plugin an admin has switched off.
|
||||||
|
func (a *app) enabledPlugins() []plugin.Plugin {
|
||||||
|
disabled, err := a.st.DisabledPlugins()
|
||||||
|
if err != nil || len(disabled) == 0 {
|
||||||
|
return a.registry
|
||||||
|
}
|
||||||
|
out := make([]plugin.Plugin, 0, len(a.registry))
|
||||||
|
for _, p := range a.registry {
|
||||||
|
if !disabled[p.ID()] {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
// emailed code, then offers $10 lifetime Premium (CoinPay)
|
// emailed code, then offers $10 lifetime Premium (CoinPay)
|
||||||
// ssh pod@host your personal Linux pod — free for verified members
|
// ssh pod@host your personal Linux pod — free for verified members
|
||||||
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
|
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
|
||||||
|
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
|
||||||
//
|
//
|
||||||
// Subcommands:
|
// Subcommands:
|
||||||
//
|
//
|
||||||
|
|
@ -74,6 +75,7 @@ type app struct {
|
||||||
sandbox *sandbox.Runner
|
sandbox *sandbox.Runner
|
||||||
mail mail.Config
|
mail mail.Config
|
||||||
fe forwardemail.Config // premium @bbs email provisioning
|
fe forwardemail.Config // premium @bbs email provisioning
|
||||||
|
live *liveReg // in-memory live-session registry (admin console)
|
||||||
dataDir string
|
dataDir string
|
||||||
assets string
|
assets string
|
||||||
host string // public hostname used in user-facing messages
|
host string // public hostname used in user-facing messages
|
||||||
|
|
@ -108,6 +110,7 @@ func main() {
|
||||||
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
|
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
|
||||||
mail: mail.ConfigFromEnv(),
|
mail: mail.ConfigFromEnv(),
|
||||||
fe: fe,
|
fe: fe,
|
||||||
|
live: newLiveReg(),
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
assets: env("AGENTBBS_ASSETS", "./assets"),
|
assets: env("AGENTBBS_ASSETS", "./assets"),
|
||||||
host: host,
|
host: host,
|
||||||
|
|
@ -167,6 +170,7 @@ func main() {
|
||||||
wish.WithIdleTimeout(30*time.Minute),
|
wish.WithIdleTimeout(30*time.Minute),
|
||||||
wish.WithMiddleware(
|
wish.WithMiddleware(
|
||||||
a.router(),
|
a.router(),
|
||||||
|
a.track(), // register every session for the admin console
|
||||||
logging.Middleware(),
|
logging.Middleware(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -194,8 +198,10 @@ func main() {
|
||||||
// a terminal (it prints and disconnects), and pod@ checks its PTY itself.
|
// a terminal (it prints and disconnects), and pod@ checks its PTY itself.
|
||||||
func (a *app) router() wish.Middleware {
|
func (a *app) router() wish.Middleware {
|
||||||
btMw := bm.Middleware(a.teaHandler)
|
btMw := bm.Middleware(a.teaHandler)
|
||||||
|
adminMw := bm.Middleware(a.adminTeaHandler)
|
||||||
return func(next ssh.Handler) ssh.Handler {
|
return func(next ssh.Handler) ssh.Handler {
|
||||||
hubHandler := activeterm.Middleware()(btMw(next))
|
hubHandler := activeterm.Middleware()(btMw(next))
|
||||||
|
adminHandler := activeterm.Middleware()(adminMw(next))
|
||||||
return func(s ssh.Session) {
|
return func(s ssh.Session) {
|
||||||
user := strings.ToLower(s.User())
|
user := strings.ToLower(s.User())
|
||||||
code, isVideo := calls.RouteCode(user)
|
code, isVideo := calls.RouteCode(user)
|
||||||
|
|
@ -204,6 +210,8 @@ func (a *app) router() wish.Middleware {
|
||||||
a.handleJoin(s)
|
a.handleJoin(s)
|
||||||
case auth.IsDomainName(user):
|
case auth.IsDomainName(user):
|
||||||
a.handleDomain(s)
|
a.handleDomain(s)
|
||||||
|
case auth.IsAdminName(user):
|
||||||
|
adminHandler(s)
|
||||||
case auth.IsPodName(user):
|
case auth.IsPodName(user):
|
||||||
a.handlePod(s)
|
a.handlePod(s)
|
||||||
case isVideo:
|
case isVideo:
|
||||||
|
|
@ -245,6 +253,10 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
wish.Fatalln(s, "account error: "+err.Error())
|
wish.Fatalln(s, "account error: "+err.Error())
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if su.Banned {
|
||||||
|
wish.Fatalln(s, "this account is suspended. Contact an operator if you think this is a mistake.")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
if su.Name != username {
|
if su.Name != username {
|
||||||
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
|
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +278,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
// URL works the moment a member first signs in.
|
// URL works the moment a member first signs in.
|
||||||
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
|
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
|
||||||
}
|
}
|
||||||
return hub.New(u, ctx, a.registry), []tea.ProgramOption{tea.WithAltScreen()}
|
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleJoin runs onboarding interactively in one SSH session: register the
|
// handleJoin runs onboarding interactively in one SSH session: register the
|
||||||
|
|
@ -681,6 +693,11 @@ func (a *app) handlePod(s ssh.Session) {
|
||||||
_ = s.Exit(1)
|
_ = s.Exit(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if u.Banned {
|
||||||
|
wish.Println(s, "this account is suspended.")
|
||||||
|
_ = s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Pods are a FREE member benefit — the only gate is a confirmed email, so
|
// Pods are a FREE member benefit — the only gate is a confirmed email, so
|
||||||
// every registered member gets their own Docker pod (set
|
// every registered member gets their own Docker pod (set
|
||||||
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to drop even that on a dev host).
|
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to drop even that on a dev host).
|
||||||
|
|
|
||||||
81
docs/admin.md
Normal file
81
docs/admin.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Admin console (M2)
|
||||||
|
|
||||||
|
The admin console is a privileged operator surface reached over SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh admin@bbs.profullstack.com
|
||||||
|
```
|
||||||
|
|
||||||
|
It is **not** a hub plugin — it never appears in the public menu. Access is
|
||||||
|
gated by the route plus an operator allowlist, so a curious member who guesses
|
||||||
|
the route still gets nothing.
|
||||||
|
|
||||||
|
## Who is an admin
|
||||||
|
|
||||||
|
Admin status is granted **only by the operator**, out of band, via an
|
||||||
|
environment variable — it can never be self-assigned in-session:
|
||||||
|
|
||||||
|
```
|
||||||
|
AGENTBBS_ADMINS="anthony,ops" # comma/space-separated account names
|
||||||
|
```
|
||||||
|
|
||||||
|
To open the console you must:
|
||||||
|
|
||||||
|
1. connect as `admin@` (or `sysop@`), **and**
|
||||||
|
2. present the SSH key of an account whose name is in `$AGENTBBS_ADMINS`.
|
||||||
|
|
||||||
|
Anyone else gets `admin@ is restricted to operators.` and is disconnected.
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
The console is a Bubble Tea TUI. Arrow keys (or `j`/`k`) move, `enter` opens a
|
||||||
|
section, `esc` goes back, `q` quits, `r` refreshes the current list.
|
||||||
|
|
||||||
|
### Users & members
|
||||||
|
Lists accounts (newest first) with kind / premium / verified flags.
|
||||||
|
|
||||||
|
- `b` — ban/unban the selected account. Banned accounts are blocked at login
|
||||||
|
on the hub and `pod@` routes. Operators cannot be banned.
|
||||||
|
|
||||||
|
### Sessions & pods
|
||||||
|
The **live** view of currently-connected SSH sessions (the in-memory registry,
|
||||||
|
distinct from the historical audit trail in the DB).
|
||||||
|
|
||||||
|
- `k` — disconnect the selected session (PRD §6 "terminate live sessions").
|
||||||
|
Your own session is marked `(you)` and is protected.
|
||||||
|
|
||||||
|
### Moderation & audit
|
||||||
|
- `tab` switches between the **admin action log** (every ban, kill, and plugin
|
||||||
|
toggle, with who/when) and recent **`agent@` transcripts** for review.
|
||||||
|
- Bans are taken from the Users section.
|
||||||
|
|
||||||
|
### Config & plugins
|
||||||
|
- Read-only runtime snapshot: host, sandbox mode, pods engine, mail status,
|
||||||
|
admin allowlist.
|
||||||
|
- `space` toggles a hub plugin on/off. The change is persisted (`plugin_state`
|
||||||
|
table) and takes effect on each member's next sign-in — disabled plugins are
|
||||||
|
filtered out of the hub menu.
|
||||||
|
|
||||||
|
## Audit trail
|
||||||
|
|
||||||
|
Every privileged action is written to the `admin_actions` table
|
||||||
|
(`admin, action, target, detail, created_at`) and is visible in the
|
||||||
|
Moderation & audit section. Connection metadata continues to land in the
|
||||||
|
`sessions` table as before.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
| Concern | Where |
|
||||||
|
|--------------------|----------------------------------------|
|
||||||
|
| Suspensions | `users.banned` |
|
||||||
|
| Plugin enable/disable | `plugin_state(id, disabled)` |
|
||||||
|
| Admin action log | `admin_actions` |
|
||||||
|
| Session audit trail| `sessions` (historical) |
|
||||||
|
| Live sessions | in-memory registry (killable) |
|
||||||
|
|
||||||
|
## Not yet (future milestones)
|
||||||
|
|
||||||
|
- AgentAd ops (creative approval, ledgers) — lands with M5.
|
||||||
|
- Live operator takeover of an `agent@` chat — the transcripts are reviewable
|
||||||
|
here today; interactive takeover is future work.
|
||||||
|
- Per-plugin config editing beyond enable/disable.
|
||||||
450
internal/admin/admin.go
Normal file
450
internal/admin/admin.go
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/ssh"
|
"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.
|
// list/add/remove the domains pointed at a member's homepage.
|
||||||
var DomainNames = map[string]bool{"domain": true, "domains": true}
|
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.
|
// IsGuestName reports whether the SSH username requests anonymous hub access.
|
||||||
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
|
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.
|
// IsDomainName reports whether the SSH username requests the custom-domain flow.
|
||||||
func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
|
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.
|
// KindFor infers the identity kind from a (non-guest) username.
|
||||||
// Usernames prefixed "agent-" are automated clients (PRD §3).
|
// Usernames prefixed "agent-" are automated clients (PRD §3).
|
||||||
func KindFor(username string) Kind {
|
func KindFor(username string) Kind {
|
||||||
|
|
|
||||||
42
internal/auth/auth_test.go
Normal file
42
internal/auth/auth_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,23 +20,25 @@ type User struct {
|
||||||
EmailVerified bool
|
EmailVerified bool
|
||||||
Premium bool // paid the one-time lifetime membership
|
Premium bool // paid the one-time lifetime membership
|
||||||
PremiumPayID string // CoinPay payment id of the pending/settled premium charge
|
PremiumPayID string // CoinPay payment id of the pending/settled premium charge
|
||||||
|
Banned bool // suspended by an admin (blocked at login)
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// userCols is the column list (in struct order) for every user SELECT, kept in
|
// userCols is the column list (in struct order) for every user SELECT, kept in
|
||||||
// sync with scanUser.
|
// 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.
|
// scanUser reads one user row selected with userCols.
|
||||||
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
||||||
var u User
|
var u User
|
||||||
var verified, premium int
|
var verified, premium, banned int
|
||||||
var created string
|
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
|
return User{}, err
|
||||||
}
|
}
|
||||||
u.EmailVerified = verified != 0
|
u.EmailVerified = verified != 0
|
||||||
u.Premium = premium != 0
|
u.Premium = premium != 0
|
||||||
|
u.Banned = banned != 0
|
||||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
@ -105,9 +107,58 @@ type Store interface {
|
||||||
DomainsForUser(username string) ([]string, error)
|
DomainsForUser(username string) ([]string, error)
|
||||||
AllDomains() ([]DomainMap, 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
|
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.
|
// ChatMessage is one line of an agent@ conversation.
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
Role string // "user" or "agent"
|
Role string // "user" or "agent"
|
||||||
|
|
@ -159,6 +210,7 @@ func migrate(db *sql.DB) error {
|
||||||
{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
|
{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"},
|
{"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"},
|
||||||
{"premium_pay_id", "premium_pay_id 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'))
|
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 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) {
|
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||||
|
|
@ -523,4 +589,146 @@ func (s *sqliteStore) AllDomains() ([]DomainMap, error) {
|
||||||
return out, rows.Err()
|
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() }
|
func (s *sqliteStore) Close() error { return s.db.Close() }
|
||||||
|
|
|
||||||
166
internal/store/store_admin_test.go
Normal file
166
internal/store/store_admin_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue