// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // CredentialsHandler handles credential management endpoints. // These endpoints require superadmin authentication. type CredentialsHandler struct { store port.CredentialStore } // NewCredentialsHandler creates a new credentials handler. func NewCredentialsHandler(store port.CredentialStore) *CredentialsHandler { return &CredentialsHandler{store: store} } // updatedBy extracts the authenticated identity from the request context. // Returns the API key name for regular keys, or "superadmin" for admin key auth // (which has ID "admin") to preserve consistency with existing database records. func updatedBy(ctx context.Context) string { if key := auth.GetAPIKey(ctx); key != nil && key.ID != "admin" { return key.Name } return "superadmin" } // Mount registers the credentials routes. // All routes require superadmin authentication (handled by middleware). func (h *CredentialsHandler) Mount(r api.Router) { r.Route("/credentials", func(r chi.Router) { r.Get("/", h.List) // GET /credentials - List all (masked) r.Post("/", h.Set) // POST /credentials - Set single r.Post("/batch", h.SetBatch) // POST /credentials/batch - Set multiple r.Get("/{key}", h.Get) // GET /credentials/{key} - Get single r.Delete("/{key}", h.Delete) // DELETE /credentials/{key} - Delete }) } // SetCredentialRequest is the request body for POST /credentials. type SetCredentialRequest struct { Key string `json:"key"` Value string `json:"value"` Description string `json:"description,omitempty"` Category string `json:"category,omitempty"` } // SetBatchRequest is the request body for POST /credentials/batch. type SetBatchRequest struct { Credentials []SetCredentialRequest `json:"credentials"` } // CredentialResponse is the response for credential endpoints. type CredentialResponse struct { Key string `json:"key"` Value string `json:"value,omitempty"` // Only included for Get, masked for List Description string `json:"description,omitempty"` Category string `json:"category,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` UpdatedBy string `json:"updated_by,omitempty"` } // List returns all credentials with values masked. // GET /credentials func (h *CredentialsHandler) List(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Check for category filter category := r.URL.Query().Get("category") var creds []domain.Credential var err error if category != "" { creds, err = h.store.ListByCategory(ctx, category) } else { creds, err = h.store.List(ctx) } if err != nil { api.WriteInternalError(w, r, "failed to list credentials") return } response := make([]CredentialResponse, len(creds)) for i, c := range creds { response[i] = CredentialResponse{ Key: c.Key, Value: c.Value, // Already masked by store Description: c.Description, Category: c.Category, CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"), UpdatedBy: c.UpdatedBy, } } api.WriteSuccess(w, r, response) } // Get retrieves a single credential by key. // GET /credentials/{key} func (h *CredentialsHandler) Get(w http.ResponseWriter, r *http.Request) { ctx := r.Context() key := chi.URLParam(r, "key") if key == "" { api.WriteBadRequest(w, r, "key is required") return } value, err := h.store.Get(ctx, key) if err != nil { api.WriteInternalError(w, r, "failed to get credential") return } if value == "" { api.WriteNotFound(w, r, "credential not found") return } api.WriteSuccess(w, r, CredentialResponse{ Key: key, Value: value, }) } // Set creates or updates a single credential. // POST /credentials func (h *CredentialsHandler) Set(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req SetCredentialRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } v := validate.New() v.Required(req.Key, "key") v.Required(req.Value, "value") if err := v.Error(); err != nil { api.WriteBadRequest(w, r, err.Error()) return } cred := domain.Credential{ Key: req.Key, Value: req.Value, Description: req.Description, Category: req.Category, UpdatedBy: updatedBy(ctx), } if err := h.store.Set(ctx, cred); err != nil { api.WriteInternalError(w, r, "failed to set credential") return } api.WriteCreated(w, r, map[string]string{ "status": "stored", "key": req.Key, }) } // SetBatch creates or updates multiple credentials. // POST /credentials/batch func (h *CredentialsHandler) SetBatch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req SetBatchRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if len(req.Credentials) == 0 { api.WriteBadRequest(w, r, "credentials array is required") return } creds := make([]domain.Credential, len(req.Credentials)) for i, c := range req.Credentials { if c.Key == "" { api.WriteBadRequest(w, r, "key is required for all credentials") return } if c.Value == "" { api.WriteBadRequest(w, r, "value is required for all credentials") return } creds[i] = domain.Credential{ Key: c.Key, Value: c.Value, Description: c.Description, Category: c.Category, UpdatedBy: updatedBy(ctx), } } if err := h.store.SetMultiple(ctx, creds); err != nil { api.WriteInternalError(w, r, "failed to set credentials") return } keys := make([]string, len(creds)) for i, c := range creds { keys[i] = c.Key } api.WriteCreated(w, r, map[string]any{ "status": "stored", "count": len(creds), "keys": keys, }) } // Delete removes a credential by key. // DELETE /credentials/{key} func (h *CredentialsHandler) Delete(w http.ResponseWriter, r *http.Request) { ctx := r.Context() key := chi.URLParam(r, "key") if key == "" { api.WriteBadRequest(w, r, "key is required") return } if err := h.store.Delete(ctx, key); err != nil { if errors.Is(err, domain.ErrCredentialNotFound) { api.WriteNotFound(w, r, "credential not found") return } api.WriteInternalError(w, r, "failed to delete credential") return } api.WriteSuccess(w, r, map[string]string{ "status": "deleted", "key": key, }) }