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.
248 lines
7.8 KiB
Go
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)
|
|
}
|
|
|