diff --git a/internal/app/admin.go b/internal/app/admin.go new file mode 100644 index 0000000..7928068 --- /dev/null +++ b/internal/app/admin.go @@ -0,0 +1,268 @@ +package app + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + "strings" + + "git.warky.dev/wdevs/amcs/internal/store" + ext "git.warky.dev/wdevs/amcs/internal/types" + + "github.com/google/uuid" +) + +type adminHandlers struct { + db *store.DB + logger *slog.Logger +} + +func newAdminHandlers(db *store.DB, logger *slog.Logger) *adminHandlers { + return &adminHandlers{db: db, logger: logger} +} + +func (h *adminHandlers) register(mux *http.ServeMux, middleware func(http.Handler) http.Handler) { + handle := func(pattern string, fn http.HandlerFunc) { + mux.Handle(pattern, middleware(fn)) + } + + handle("GET /api/admin/projects", h.listProjects) + handle("POST /api/admin/projects", h.createProject) + handle("GET /api/admin/thoughts", h.listThoughts) + handle("GET /api/admin/thoughts/{id}", h.getThought) + handle("DELETE /api/admin/thoughts/{id}", h.deleteThought) + handle("POST /api/admin/thoughts/{id}/archive", h.archiveThought) + handle("GET /api/admin/skills", h.listSkills) + handle("DELETE /api/admin/skills/{id}", h.deleteSkill) + handle("GET /api/admin/guardrails", h.listGuardrails) + handle("DELETE /api/admin/guardrails/{id}", h.deleteGuardrail) + handle("GET /api/admin/files", h.listFiles) + handle("GET /api/admin/stats", h.stats) +} + +// --- Projects --- + +func (h *adminHandlers) listProjects(w http.ResponseWriter, r *http.Request) { + projects, err := h.db.ListProjects(r.Context()) + if err != nil { + h.internalError(w, "list projects", err) + return + } + writeJSON(w, projects) +} + +func (h *adminHandlers) createProject(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(body.Name) == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + project, err := h.db.CreateProject(r.Context(), body.Name, body.Description) + if err != nil { + h.internalError(w, "create project", err) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, project) +} + +// --- Thoughts --- + +func (h *adminHandlers) listThoughts(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + limit := 50 + if l := q.Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = min(n, 200) + } + } + + query := strings.TrimSpace(q.Get("q")) + includeArchived := q.Get("include_archived") == "true" + + var projectID *uuid.UUID + if pid := q.Get("project_id"); pid != "" { + if id, err := uuid.Parse(pid); err == nil { + projectID = &id + } + } + + if query != "" { + results, err := h.db.SearchThoughtsText(r.Context(), query, limit, projectID, nil) + if err != nil { + h.internalError(w, "search thoughts", err) + return + } + writeJSON(w, results) + return + } + + thoughts, err := h.db.ListThoughts(r.Context(), ext.ListFilter{ + Limit: limit, + ProjectID: projectID, + IncludeArchived: includeArchived, + }) + if err != nil { + h.internalError(w, "list thoughts", err) + return + } + writeJSON(w, thoughts) +} + +func (h *adminHandlers) getThought(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(w, r.PathValue("id")) + if !ok { + return + } + thought, err := h.db.GetThought(r.Context(), id) + if err != nil { + h.internalError(w, "get thought", err) + return + } + writeJSON(w, thought) +} + +func (h *adminHandlers) deleteThought(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(w, r.PathValue("id")) + if !ok { + return + } + if err := h.db.DeleteThought(r.Context(), id); err != nil { + h.internalError(w, "delete thought", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *adminHandlers) archiveThought(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(w, r.PathValue("id")) + if !ok { + return + } + if err := h.db.ArchiveThought(r.Context(), id); err != nil { + h.internalError(w, "archive thought", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Skills --- + +func (h *adminHandlers) listSkills(w http.ResponseWriter, r *http.Request) { + tag := r.URL.Query().Get("tag") + skills, err := h.db.ListSkills(r.Context(), tag) + if err != nil { + h.internalError(w, "list skills", err) + return + } + writeJSON(w, skills) +} + +func (h *adminHandlers) deleteSkill(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(w, r.PathValue("id")) + if !ok { + return + } + if err := h.db.RemoveSkill(r.Context(), id); err != nil { + h.internalError(w, "delete skill", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Guardrails --- + +func (h *adminHandlers) listGuardrails(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + guardrails, err := h.db.ListGuardrails(r.Context(), q.Get("tag"), q.Get("severity")) + if err != nil { + h.internalError(w, "list guardrails", err) + return + } + writeJSON(w, guardrails) +} + +func (h *adminHandlers) deleteGuardrail(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(w, r.PathValue("id")) + if !ok { + return + } + if err := h.db.RemoveGuardrail(r.Context(), id); err != nil { + h.internalError(w, "delete guardrail", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Files --- + +func (h *adminHandlers) listFiles(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + limit := 100 + if l := q.Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = min(n, 500) + } + } + + filter := ext.StoredFileFilter{Limit: limit} + if pid := q.Get("project_id"); pid != "" { + if id, err := uuid.Parse(pid); err == nil { + filter.ProjectID = &id + } + } + if tid := q.Get("thought_id"); tid != "" { + if id, err := uuid.Parse(tid); err == nil { + filter.ThoughtID = &id + } + } + filter.Kind = q.Get("kind") + + files, err := h.db.ListStoredFiles(r.Context(), filter) + if err != nil { + h.internalError(w, "list files", err) + return + } + writeJSON(w, files) +} + +// --- Stats --- + +func (h *adminHandlers) stats(w http.ResponseWriter, r *http.Request) { + stats, err := h.db.Stats(r.Context()) + if err != nil { + h.internalError(w, "stats", err) + return + } + writeJSON(w, stats) +} + +// --- Helpers --- + +func (h *adminHandlers) internalError(w http.ResponseWriter, op string, err error) { + h.logger.Error("admin handler error", slog.String("op", op), slog.String("error", err.Error())) + http.Error(w, "internal server error", http.StatusInternalServerError) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func parseUUID(w http.ResponseWriter, s string) (uuid.UUID, bool) { + id, err := uuid.Parse(s) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return uuid.UUID{}, false + } + return id, true +} + diff --git a/internal/app/app.go b/internal/app/app.go index 305727f..0b63611 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -92,12 +92,12 @@ func Run(ctx context.Context, configPath string) error { return err } } + tokenStore = auth.NewTokenStore(0) if len(cfg.Auth.OAuth.Clients) > 0 { oauthRegistry, err = auth.NewOAuthRegistry(cfg.Auth.OAuth.Clients) if err != nil { return err } - tokenStore = auth.NewTokenStore(0) } authCodes := auth.NewAuthCodeStore() dynClients := auth.NewDynamicClientStore() @@ -186,7 +186,7 @@ func Run(ctx context.Context, configPath string) error { func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, bgEmbeddings *ai.EmbeddingRunner, bgMetadata *ai.MetadataRunner, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) { mux := http.NewServeMux() accessTracker := auth.NewAccessTracker() - oauthEnabled := oauthRegistry != nil && tokenStore != nil + oauthEnabled := oauthRegistry != nil authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger) filesTool := tools.NewFilesTool(db, activeProjects) enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger) @@ -227,16 +227,13 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st mux.Handle(cfg.MCP.SSEPath, authMiddleware(mcpHandlers.SSE)) logger.Info("SSE transport enabled", slog.String("sse_path", cfg.MCP.SSEPath)) } + newAdminHandlers(db, logger).register(mux, authMiddleware) mux.Handle("/files", authMiddleware(fileHandler(filesTool))) mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool))) - if oauthEnabled { - mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler()) - mux.HandleFunc("/oauth-authorization-server", oauthMetadataHandler()) - mux.HandleFunc("/oauth/register", oauthRegisterHandler(dynClients, logger)) - mux.HandleFunc("/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger)) - mux.HandleFunc("/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger)) - mux.HandleFunc("/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger)) - } + mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler()) + mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger)) + mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger)) + mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger)) mux.HandleFunc("/favicon.ico", serveFavicon) mux.HandleFunc("/images/project.jpg", serveHomeImage) mux.HandleFunc("/images/icon.png", serveIcon) diff --git a/internal/app/oauth.go b/internal/app/oauth.go index 6a8731b..9dbe787 100644 --- a/internal/app/oauth.go +++ b/internal/app/oauth.go @@ -67,9 +67,9 @@ func oauthMetadataHandler() http.HandlerFunc { base := serverBaseURL(r) meta := oauthServerMetadata{ Issuer: base, - AuthorizationEndpoint: base + "/authorize", - TokenEndpoint: base + "/oauth/token", - RegistrationEndpoint: base + "/oauth/register", + AuthorizationEndpoint: base + "/api/oauth/authorize", + TokenEndpoint: base + "/api/oauth/token", + RegistrationEndpoint: base + "/api/oauth/register", ScopesSupported: []string{"mcp"}, ResponseTypesSupported: []string{"code"}, GrantTypesSupported: []string{"authorization_code", "client_credentials"}, @@ -244,6 +244,10 @@ func oauthTokenHandler(oauthRegistry *auth.OAuthRegistry, tokenStore *auth.Token switch r.FormValue("grant_type") { case "client_credentials": + if oauthRegistry == nil { + writeTokenError(w, "unsupported_grant_type", http.StatusBadRequest) + return + } handleClientCredentials(w, r, oauthRegistry, tokenStore, log) case "authorization_code": handleAuthorizationCode(w, r, authCodes, tokenStore, log) @@ -334,7 +338,7 @@ button{padding:.5rem 1.2rem;margin-right:.5rem;cursor:pointer;font-size:1rem}
%s is requesting access to this AMCS server.
-