rdev/internal/handlers/credentials.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

252 lines
6.6 KiB
Go

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