package server import ( "net/http" "github.com/zzet/gortex/internal/llm/conversationlog" ) // Conversation-log inspection routes. They expose the opt-in // conversation-log sink (raw LLM request/response JSONL per session) to // a local inspector: // // GET /v1/conversations list recorded sessions // GET /v1/conversations/{session} per-file/phase request/response steps + usage // GET /v1/conversations/ui a minimal self-contained HTML inspector // // Because these routes egress raw LLM I/O, each one applies the // route-scoped DNS-rebind guard (guardConversationRoute) before // responding. The guard cooperates with the existing --http-auth-token // model: it allows loopback / allowlisted hosts OR a valid token. It is // deliberately NOT installed in ServeHTTP, so no other route is affected // and a token-authed non-loopback dashboard keeps working. // SetConversationDir enables the /v1/conversations* routes by pointing // them at the conversation-log directory. An empty dir leaves the routes // mounted but reporting no sessions (the sink is off). func (h *Handler) SetConversationDir(dir string) { h.convDir = dir } // SetConversationGuard wires the route-scoped DNS-rebind guard for the // conversation routes: an extra Host allowlist (beyond loopback) and the // auth-token source so a valid token-authed request passes. tokenFn may // be nil (no token configured); allow may be empty (loopback-only). func (h *Handler) SetConversationGuard(allow []string, tokenFn func() string) { h.convAllow = allow h.convTokenFn = tokenFn } // conversationTokenOK reports whether the request carries a valid auth // token, matching the source-of-truth check in WithAuthFunc (Bearer // header or ?token=). When no token is configured, there is nothing to // present, so this returns false and the guard falls back to the // loopback/allowlist check. func (h *Handler) conversationTokenOK(r *http.Request) bool { if h.convTokenFn == nil { return false } expected := h.convTokenFn() if expected == "" { return false } if authMatches([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) { return true } if q := r.URL.Query().Get("token"); q != "" && tokenMatches([]byte(q), []byte(expected)) { return true } return false } // guardConversation runs the route-scoped guard for one request and // writes the 403 response when it fails. Returns true when the request // may proceed. func (h *Handler) guardConversation(w http.ResponseWriter, r *http.Request) bool { if guardConversationRoute(r, h.convAllow, h.conversationTokenOK(r)) { return true } WriteJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden host"}) return false } // conversationReader returns a reader over the configured conversation // directory (which may be empty, yielding an empty session list). func (h *Handler) conversationReader() *conversationlog.Reader { return conversationlog.NewReader(h.convDir) } // --- GET /v1/conversations --- func (h *Handler) handleConversations(w http.ResponseWriter, r *http.Request) { if !h.guardConversation(w, r) { return } sessions, err := h.conversationReader().ListSessions() if err != nil { WriteJSONError(w, http.StatusInternalServerError, "failed to read conversation sessions") return } if sessions == nil { sessions = []conversationlog.SessionSummary{} } WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) } // --- GET /v1/conversations/{session} --- func (h *Handler) handleConversationSession(w http.ResponseWriter, r *http.Request) { if !h.guardConversation(w, r) { return } session := r.PathValue("session") if session == "" { WriteJSONError(w, http.StatusBadRequest, "missing session id") return } recs, err := h.conversationReader().LoadSession(session) if err != nil { WriteJSONError(w, http.StatusInternalServerError, "failed to load session") return } total := len(recs) // Optional ?file= / ?phase= filters narrow the records to one // file/phase, mirroring the /v1/activity filter idiom. fileFilter := r.URL.Query().Get("file") phaseFilter := r.URL.Query().Get("phase") filtered := recs if fileFilter != "" || phaseFilter != "" { filtered = filtered[:0:0] for _, rec := range recs { if fileFilter != "" && rec.File != fileFilter { continue } if phaseFilter != "" && rec.Phase != phaseFilter { continue } filtered = append(filtered, rec) } } if filtered == nil { filtered = []conversationlog.Record{} } WriteJSON(w, http.StatusOK, map[string]any{ "session": session, "records": filtered, "total": total, "filtered": len(filtered), }) } // --- GET /v1/conversations/ui --- func (h *Handler) handleConversationsUI(w http.ResponseWriter, r *http.Request) { if !h.guardConversation(w, r) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(conversationInspectorHTML)) } // conversationInspectorHTML is a minimal, self-contained inspector page: // it lists sessions, lets you pick one, and renders each turn's request // messages, response, and token usage. No external assets — it talks to // the same-origin /v1/conversations JSON routes. const conversationInspectorHTML = `