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) }