Files
hzhang 0b16b52ee7 feat(admin): GET /api/admin/agents/{id} activity summary
New endpoint for operator diagnostics (used by the Dialectic.Frontend
AgentActivity page). Same x-dialectic-admin-key gate as
ProvisionAgentKey. Returns:

  - key_provisioned (bool) + last_used_at if available
  - signups_count / arguments_count / verdicts_count
  - recent_topics[]: up to 20 topics the agent touched in any role
    (volunteer → camp-allocated → pro/con poster → judge),
    deduped by (topic_id, role), most recent action_at first

Implementation: 3 small COUNT queries + one UNION-ALL across signups +
camps + arguments + verdicts joined to topics. Caps at 20 rows; bounded
by per-table indexes on agent_id / posted_at / created_at. <50ms at
current sim row counts.

No new tables / migrations. Roll out: re-deploy backend; frontend
prompts for the admin key on first visit and stores in localStorage.
2026-05-24 00:14:18 +01:00

248 lines
7.8 KiB
Go

package handlers
import (
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth"
)
type AdminHandler struct {
db *sqlx.DB
pepper string
adminAPIKey string
}
func NewAdminHandler(db *sqlx.DB, pepper string, adminAPIKey string) *AdminHandler {
return &AdminHandler{db: db, pepper: pepper, adminAPIKey: adminAPIKey}
}
type provisionAgentKeyBody struct {
AgentID string `json:"agent_id"`
Force bool `json:"force"`
}
// POST /api/admin/agent-keys
//
// System-auth (header `x-dialectic-admin-key` matching env
// `DIALECTIC_ADMIN_API_KEY`). Generates a fresh 32-byte hex random
// raw key, stores its peppered-sha256 hash in `agent_keys`, and
// returns the raw key in the response. This is the ONLY time the raw
// key is exposed — caller must capture it and place in the target
// agent's secret-mgr.
//
// On duplicate (agent_id already has a key): 409 unless `force: true`,
// in which case the old hash is replaced.
//
// Caller pattern: `dialectic-ctrl create-key` (run via proxy-pcexec
// with proxy-for set to the agent being onboarded — see
// `skills/dialectic-hangman-lab/scripts/dialectic-ctrl`).
func (h *AdminHandler) ProvisionAgentKey(w http.ResponseWriter, r *http.Request) {
if !h.checkAdminAuth(r) {
http.Error(w, "admin auth required (x-dialectic-admin-key)", http.StatusUnauthorized)
return
}
if h.pepper == "" {
http.Error(w, "server misconfigured: AGENT_API_KEY_PEPPER unset", http.StatusInternalServerError)
return
}
var body provisionAgentKeyBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if body.AgentID == "" {
http.Error(w, "agent_id required", http.StatusBadRequest)
return
}
rawKey, err := randomHexKey(32)
if err != nil {
http.Error(w, "rng failed: "+err.Error(), http.StatusInternalServerError)
return
}
hash := auth.HashKey(h.pepper, rawKey)
_, err = h.db.ExecContext(r.Context(),
`INSERT INTO agent_keys (agent_id, key_hash) VALUES (?, ?)`,
body.AgentID, hash)
if err != nil {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
// duplicate primary key
if !body.Force {
http.Error(w, fmt.Sprintf(
"agent %q already has a dialectic api key; pass force:true to rotate",
body.AgentID), http.StatusConflict)
return
}
// rotate: replace the hash
if _, err := h.db.ExecContext(r.Context(),
`UPDATE agent_keys SET key_hash = ?, last_used_at = NULL, revoked_at = NULL WHERE agent_id = ?`,
hash, body.AgentID); err != nil {
http.Error(w, "rotate failed: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "insert failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, http.StatusCreated, map[string]any{
"agent_id": body.AgentID,
"api_key": rawKey, // raw — caller must store in agent's secret-mgr; not returned again
"rotated": body.Force,
})
}
func (h *AdminHandler) checkAdminAuth(r *http.Request) bool {
if h.adminAPIKey == "" {
return false // unset env = no admin caller is valid
}
got := r.Header.Get("x-dialectic-admin-key")
if got == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(h.adminAPIKey)) == 1
}
func randomHexKey(byteLen int) (string, error) {
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// GET /api/admin/agents/{id}
//
// Same x-dialectic-admin-key gate as ProvisionAgentKey. Returns a
// rolled-up activity summary for one agent: whether a dialectic key is
// provisioned (and when last used), per-action counts (signups /
// arguments / verdicts), and the 20 most-recent topics the agent
// touched in any role. Used by the frontend AgentActivity page for
// operator diagnostics ("did sherlock get a key?", "how much is mirror
// participating?").
//
// Joins are wide but capped at 20 recent topics; total query cost is
// bounded by index scans on (agent_id, posted_at/created_at). At
// current row counts (low thousands) returns in <50ms.
type recentTopic struct {
TopicID string `db:"topic_id" json:"topic_id"`
Title string `db:"title" json:"title"`
Status string `db:"status" json:"status"`
Role string `db:"role" json:"role"`
LastActionAt time.Time `db:"last_action_at" json:"last_action_at"`
}
func (h *AdminHandler) GetAgentSummary(w http.ResponseWriter, r *http.Request) {
if !h.checkAdminAuth(r) {
http.Error(w, "admin auth required (x-dialectic-admin-key)", http.StatusUnauthorized)
return
}
agentID := chi.URLParam(r, "id")
if agentID == "" {
http.Error(w, "agent_id required in path", http.StatusBadRequest)
return
}
ctx := r.Context()
// agent_keys row (may be missing — agent never provisioned)
var lastUsedAt sql.NullTime
var keyProvisioned bool
if err := h.db.QueryRowxContext(ctx,
`SELECT last_used_at FROM agent_keys WHERE agent_id = ?`, agentID,
).Scan(&lastUsedAt); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
http.Error(w, "agent_keys lookup: "+err.Error(), http.StatusInternalServerError)
return
}
keyProvisioned = false
} else {
keyProvisioned = true
}
// Counts (3 small queries; could be one UNION but readability wins
// at this scale).
count := func(q string) (int, error) {
var n int
err := h.db.GetContext(ctx, &n, q, agentID)
return n, err
}
signups, err := count(`SELECT COUNT(*) FROM signups WHERE agent_id = ?`)
if err != nil {
http.Error(w, "signups count: "+err.Error(), http.StatusInternalServerError)
return
}
args, err := count(`SELECT COUNT(*) FROM arguments WHERE agent_id = ?`)
if err != nil {
http.Error(w, "arguments count: "+err.Error(), http.StatusInternalServerError)
return
}
verdicts, err := count(`SELECT COUNT(*) FROM verdicts WHERE judge_agent_id = ?`)
if err != nil {
http.Error(w, "verdicts count: "+err.Error(), http.StatusInternalServerError)
return
}
// Recent topics: union 3 sources, dedup by topic_id keeping the
// latest action_at + most-specific role. Allocator camps win over
// raw signups (an allocated agent's role is meaningful; a signup
// without allocation is just "volunteer"). Verdicts row is judge.
var recent []recentTopic
if err := h.db.SelectContext(ctx, &recent,
`SELECT topic_id, title, status, role, MAX(last_action_at) AS last_action_at FROM (
SELECT s.topic_id, t.title, t.status, 'volunteer' AS role, s.created_at AS last_action_at
FROM signups s JOIN topics t ON t.id = s.topic_id
WHERE s.agent_id = ?
UNION ALL
SELECT c.topic_id, t.title, t.status, c.camp AS role, c.allocated_at AS last_action_at
FROM camps c JOIN topics t ON t.id = c.topic_id
WHERE c.agent_id = ?
UNION ALL
SELECT a.topic_id, t.title, t.status, a.camp AS role, a.posted_at AS last_action_at
FROM arguments a JOIN topics t ON t.id = a.topic_id
WHERE a.agent_id = ?
UNION ALL
SELECT v.topic_id, t.title, t.status, 'judge' AS role, v.produced_at AS last_action_at
FROM verdicts v JOIN topics t ON t.id = v.topic_id
WHERE v.judge_agent_id = ?
) AS u
GROUP BY topic_id, title, status, role
ORDER BY last_action_at DESC
LIMIT 20`,
agentID, agentID, agentID, agentID,
); err != nil {
http.Error(w, "recent topics: "+err.Error(), http.StatusInternalServerError)
return
}
resp := map[string]any{
"agent_id": agentID,
"key_provisioned": keyProvisioned,
"signups_count": signups,
"arguments_count": args,
"verdicts_count": verdicts,
"recent_topics": recent,
}
if lastUsedAt.Valid {
resp["last_used_at"] = lastUsedAt.Time
}
writeJSON(w, http.StatusOK, resp)
}