package api import ( "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/store" ) // sharedSecretRow is the JSON shape returned to clients. The secret value is // NEVER returned — once stored it is write-only (mirroring workload_env). The // has_value flag lets the UI show whether a value is set without exposing it; // to rotate, the operator submits a new value. type sharedSecretRow struct { ID string `json:"id"` Name string `json:"name"` HasValue bool `json:"has_value"` Encrypted bool `json:"encrypted"` Scope string `json:"scope"` AppID string `json:"app_id"` Description string `json:"description"` Enabled bool `json:"enabled"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func toSharedSecretRow(sec store.SharedSecret) sharedSecretRow { return sharedSecretRow{ ID: sec.ID, Name: sec.Name, HasValue: sec.Value != "", Encrypted: sec.Encrypted, Scope: sec.Scope, AppID: sec.AppID, Description: sec.Description, Enabled: sec.Enabled, CreatedAt: sec.CreatedAt, UpdatedAt: sec.UpdatedAt, } } // listSharedSecrets handles GET /api/shared-secrets. Values are redacted. func (s *Server) listSharedSecrets(w http.ResponseWriter, r *http.Request) { rows, err := s.store.ListSharedSecrets() if err != nil { respondError(w, http.StatusInternalServerError, "list shared secrets") return } out := make([]sharedSecretRow, 0, len(rows)) for _, sec := range rows { out = append(out, toSharedSecretRow(sec)) } respondJSON(w, http.StatusOK, out) } // getSharedSecret handles GET /api/shared-secrets/{id}. Value is redacted. func (s *Server) getSharedSecret(w http.ResponseWriter, r *http.Request) { sec, err := s.store.GetSharedSecret(chi.URLParam(r, "id")) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "shared secret") return } respondError(w, http.StatusInternalServerError, "get shared secret") return } respondJSON(w, http.StatusOK, toSharedSecretRow(sec)) } // createSharedSecretRequest is the POST body. Encrypted=true (the default for // a non-empty value) causes the value to be encrypted at rest with the global // key before it ever reaches the store. type createSharedSecretRequest struct { Name string `json:"name"` Value string `json:"value"` Encrypted *bool `json:"encrypted"` // defaults true Scope string `json:"scope"` // global | app AppID string `json:"app_id"` // required when scope == app Description string `json:"description"` Enabled *bool `json:"enabled"` // defaults true } func (s *Server) createSharedSecret(w http.ResponseWriter, r *http.Request) { var req createSharedSecretRequest if !decodeJSONStrict(w, r, &req) { return } req.Name = strings.TrimSpace(req.Name) if !validEnvKey(req.Name) { respondError(w, http.StatusBadRequest, "name must be a valid env key [A-Za-z_][A-Za-z0-9_]*") return } if msg := validateSharedSecretScope(req.Scope, req.AppID); msg != "" { respondError(w, http.StatusBadRequest, msg) return } encrypted := true if req.Encrypted != nil { encrypted = *req.Encrypted } enabled := true if req.Enabled != nil { enabled = *req.Enabled } value, err := s.encryptSecretValue(req.Value, encrypted) if err != nil { respondError(w, http.StatusInternalServerError, "encrypt value") return } sec, err := s.store.CreateSharedSecret(store.SharedSecret{ Name: req.Name, Value: value, Encrypted: encrypted, Scope: req.Scope, AppID: strings.TrimSpace(req.AppID), Description: req.Description, Enabled: enabled, }) if err != nil { if errors.Is(err, store.ErrUnique) { respondError(w, http.StatusConflict, "a shared secret with this scope and name already exists") return } respondError(w, http.StatusInternalServerError, "create shared secret") return } respondJSON(w, http.StatusCreated, toSharedSecretRow(sec)) } // updateSharedSecretRequest is the PATCH body. Every field is optional; nil // means "leave unchanged". A nil Value preserves the stored ciphertext (so a // metadata-only edit can't accidentally blank a secret); a non-nil Value // rotates it (re-encrypted under the effective Encrypted flag). type updateSharedSecretRequest struct { Name *string `json:"name"` Value *string `json:"value"` Encrypted *bool `json:"encrypted"` Scope *string `json:"scope"` AppID *string `json:"app_id"` Description *string `json:"description"` Enabled *bool `json:"enabled"` } func (s *Server) updateSharedSecret(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") existing, err := s.store.GetSharedSecret(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "shared secret") return } respondError(w, http.StatusInternalServerError, "get shared secret") return } var req updateSharedSecretRequest if !decodeJSONStrict(w, r, &req) { return } merged := existing if req.Name != nil { merged.Name = strings.TrimSpace(*req.Name) if !validEnvKey(merged.Name) { respondError(w, http.StatusBadRequest, "name must be a valid env key [A-Za-z_][A-Za-z0-9_]*") return } } if req.Encrypted != nil { merged.Encrypted = *req.Encrypted } if req.Scope != nil { merged.Scope = *req.Scope } if req.AppID != nil { merged.AppID = strings.TrimSpace(*req.AppID) } if req.Description != nil { merged.Description = *req.Description } if req.Enabled != nil { merged.Enabled = *req.Enabled } if msg := validateSharedSecretScope(merged.Scope, merged.AppID); msg != "" { respondError(w, http.StatusBadRequest, msg) return } // Value handling: only (re)encrypt when the caller supplied a new value. // Otherwise keep the stored ciphertext untouched — but if the Encrypted // flag flipped without a new value we cannot transcode the opaque stored // bytes, so reject that ambiguous request rather than corrupting the row. if req.Value != nil { v, encErr := s.encryptSecretValue(*req.Value, merged.Encrypted) if encErr != nil { respondError(w, http.StatusInternalServerError, "encrypt value") return } merged.Value = v } else if req.Encrypted != nil && *req.Encrypted != existing.Encrypted { respondError(w, http.StatusBadRequest, "changing 'encrypted' requires resubmitting 'value'") return } sec, err := s.store.UpdateSharedSecret(merged) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "shared secret") return } if errors.Is(err, store.ErrUnique) { respondError(w, http.StatusConflict, "a shared secret with this scope and name already exists") return } respondError(w, http.StatusInternalServerError, "update shared secret") return } respondJSON(w, http.StatusOK, toSharedSecretRow(sec)) } func (s *Server) deleteSharedSecret(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := s.store.DeleteSharedSecret(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "shared secret") return } respondError(w, http.StatusInternalServerError, "delete shared secret") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) } // encryptSecretValue encrypts value with the global key when encrypted is set // and the value is non-empty; otherwise it returns the value unchanged. An // empty value stays empty (no value set) regardless of the flag. func (s *Server) encryptSecretValue(value string, encrypted bool) (string, error) { if !encrypted || value == "" { return value, nil } enc, err := crypto.Encrypt(s.encKey, value) if err != nil { slog.Error("encrypt shared secret value", "error", err) return "", err } return enc, nil } // validateSharedSecretScope returns a non-empty 400 message when the scope / // app_id pairing is invalid; "" when valid. Mirrors the store-side invariant // so the API rejects with a clear message before hitting the store. func validateSharedSecretScope(scope, appID string) string { switch scope { case store.SharedSecretScopeGlobal: return "" case store.SharedSecretScopeApp: if strings.TrimSpace(appID) == "" { return "app_id is required when scope is 'app'" } return "" default: return "scope must be 'global' or 'app'" } }