Repository: akuity/kargo
Pull request #579 (2023-08-17 19:07:02)

refactor handlers for simplicity
There's a lot of unnecessary complexity in the handler pattern we've been using.

Other than vanity (handlers in their own package because of some vague sense that, hierarchically, they are deserving of their own folder), there is no real reason for handlers to be in their own separate package. No one besides the API server uses these. They are, unquestionably _part of_ the API server -- which does very little _besides_ delegate to these handlers.

The choice to keep the handlers in a separate package also forced us into several layers of indirection that were hard to follow and it also forced our hand on moving server config to a separate package to break a dependency cycle that otherwise would not have existed. (imho, it's more straightforward to users of a package if config types live in the same package as the things they configure, so the fact that we were forced into this created another smell.)

This PR moves handler functions into the `api` package and refactors them to use the `server` type as a receiver, which eliminates a lot of indirection that was needed to accommodate passing around things like kubeclients when handlers were in a separate package.

Overall this should be cleaner, easier to follow, and easier to replicate when new handlers are added.

Note to reviewers: When files are moved _and_ modified, Git has a harder time recognizing it as a move/rename, so I broke this PR into two commits: one to move files without any changes and another to actually perform the changes. This means it will be easiest to review this PR by looking directly at commit aeaf2c9919b404b167d37cc1789278c2959eedf6

There are no functional changes in this PR.

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *docs-kargo-akuity-io* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | aeaf2c9919b404b167d37cc1789278c2959eedf6 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-akuity-io/deploys/64de6fd987399400081c5571 |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) Report
> Merging [#579](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) (aeaf2c9) into [main](https://app.codecov.io/gh/akuity/kargo/commit/4b0a286e7dc998cb0cd1f4531c715208a9374414?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) (4b0a286) will **increase** coverage by `0.00%`.
> The diff coverage is `14.74%`.

```diff
@@           Coverage Diff           @@
##             main     #579   +/-   ##
=======================================
  Coverage   44.58%   44.59%           
=======================================
  Files          74       76    +2     
  Lines        5123     5086   -37     
=======================================
- Hits         2284     2268   -16     
+ Misses       2735     2714   -21     
  Partials      104      104           
```


| [Files Changed](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | Coverage Δ | |
|---|---|---|
| [internal/api/admin\_login\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2FkbWluX2xvZ2luX3YxYWxwaGExLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/api/create\_project\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2NyZWF0ZV9wcm9qZWN0X3YxYWxwaGExLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/api/create\_promotion\_policy\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2NyZWF0ZV9wcm9tb3Rpb25fcG9saWN5X3YxYWxwaGExLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/api/create\_stage\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2NyZWF0ZV9zdGFnZV92MWFscGhhMS5nbw==) | `0.00% <0.00%> (ø)` | |
| [internal/api/delete\_project\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2RlbGV0ZV9wcm9qZWN0X3YxYWxwaGExLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/api/delete\_promotion\_policy\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2RlbGV0ZV9wcm9tb3Rpb25fcG9saWN5X3YxYWxwaGExLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/api/delete\_stage\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2RlbGV0ZV9zdGFnZV92MWFscGhhMS5nbw==) | `0.00% <0.00%> (ø)` | |
| [internal/api/get\_promotion\_policy\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2dldF9wcm9tb3Rpb25fcG9saWN5X3YxYWxwaGExLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/api/get\_public\_config\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2dldF9wdWJsaWNfY29uZmlnX3YxYWxwaGExLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/api/get\_version\_info\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2dldF92ZXJzaW9uX2luZm9fdjFhbHBoYTEuZ28=) | `0.00% <0.00%> (ø)` | |
| ... and [11 more](https://app.codecov.io/gh/akuity/kargo/pull/579?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/akuity/kargo/pull/579/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity)



Patch:
diff --git a/internal/api/admin_login_v1alpha1.go b/internal/api/admin_login_v1alpha1.go
new file mode 100644
index 00000000..535a6ff8
--- /dev/null
+++ b/internal/api/admin_login_v1alpha1.go
@@ -0,0 +1,66 @@
+package api
+
+import (
+	"context"
+	"time"
+
+	"connectrpc.com/connect"
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/pkg/errors"
+	uuid "github.com/satori/go.uuid"
+	"golang.org/x/crypto/bcrypt"
+
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func init() {
+	jwt.MarshalSingleStringAsArray = false
+}
+
+func (s *server) AdminLogin(
+	_ context.Context,
+	req *connect.Request[svcv1alpha1.AdminLoginRequest],
+) (*connect.Response[svcv1alpha1.AdminLoginResponse], error) {
+	if s.cfg.AdminConfig == nil {
+		return nil, connect.NewError(
+			connect.CodePermissionDenied,
+			errors.New("admin user is not enabled"),
+		)
+	}
+
+	if err := bcrypt.CompareHashAndPassword(
+		[]byte(s.cfg.AdminConfig.HashedPassword),
+		[]byte(req.Msg.Password),
+	); err != nil {
+		return nil, connect.NewError(
+			connect.CodePermissionDenied,
+			errors.New("invalid password"),
+		)
+	}
+
+	now := time.Now()
+	idToken := jwt.NewWithClaims(
+		jwt.SigningMethodHS256,
+		jwt.RegisteredClaims{
+			IssuedAt:  jwt.NewNumericDate(now),
+			Issuer:    s.cfg.AdminConfig.TokenIssuer,
+			Audience:  []string{s.cfg.AdminConfig.TokenAudience},
+			NotBefore: jwt.NewNumericDate(now),
+			Subject:   "admin",
+			ID:        uuid.NewV4().String(),
+			ExpiresAt: jwt.NewNumericDate(now.Add(s.cfg.AdminConfig.TokenTTL)),
+		},
+	)
+
+	signedToken, err := idToken.SignedString(s.cfg.AdminConfig.TokenSigningKey)
+	if err != nil {
+		return nil, connect.NewError(
+			connect.CodeInternal,
+			errors.Wrap(err, "error signing ID token"),
+		)
+	}
+
+	return connect.NewResponse(&svcv1alpha1.AdminLoginResponse{
+		IdToken: signedToken,
+	}), nil
+}
diff --git a/internal/api/create_project_v1alpha1.go b/internal/api/create_project_v1alpha1.go
new file mode 100644
index 00000000..442547e3
--- /dev/null
+++ b/internal/api/create_project_v1alpha1.go
@@ -0,0 +1,59 @@
+package api
+
+import (
+	"context"
+	"strings"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	"google.golang.org/protobuf/types/known/timestamppb"
+	corev1 "k8s.io/api/core/v1"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/akuity/kargo/api/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) CreateProject(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.CreateProjectRequest],
+) (*connect.Response[svcv1alpha1.CreateProjectResponse], error) {
+	name := strings.TrimSpace(req.Msg.GetName())
+	if name == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+	}
+
+	var existingNs corev1.Namespace
+	if err := s.client.Get(ctx, client.ObjectKey{Name: name}, &existingNs); err == nil || !kubeerr.IsNotFound(err) {
+		if err != nil {
+			return nil, connect.NewError(connect.CodeInternal,
+				errors.Wrap(err, "get existing namespace"))
+		}
+		if existingNs.GetLabels()[v1alpha1.LabelProjectKey] == v1alpha1.LabelTrueValue {
+			return nil, connect.NewError(connect.CodeAlreadyExists,
+				errors.Errorf("project %q already exists", name))
+		}
+		return nil, connect.NewError(connect.CodeFailedPrecondition,
+			errors.Errorf("non-project namespace %q already exists", name))
+	}
+
+	ns := corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Labels: map[string]string{
+				v1alpha1.LabelProjectKey: v1alpha1.LabelTrueValue,
+			},
+			Name: name,
+		},
+	}
+	if err := s.client.Create(ctx, &ns); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.CreateProjectResponse{
+		Project: &svcv1alpha1.Project{
+			Name:       ns.Name,
+			CreateTime: timestamppb.New(ns.CreationTimestamp.Time),
+		},
+	}), nil
+}
diff --git a/internal/api/create_promotion_policy_v1alpha1.go b/internal/api/create_promotion_policy_v1alpha1.go
new file mode 100644
index 00000000..615ef644
--- /dev/null
+++ b/internal/api/create_promotion_policy_v1alpha1.go
@@ -0,0 +1,58 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/yaml"
+
+	"github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) CreatePromotionPolicy(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.CreatePromotionPolicyRequest],
+) (*connect.Response[svcv1alpha1.CreatePromotionPolicyResponse], error) {
+	var policy v1alpha1.PromotionPolicy
+	switch {
+	case req.Msg.GetYaml() != "":
+		if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &policy); err != nil {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
+		}
+	case req.Msg.GetTyped() != nil:
+		if req.Msg.GetTyped().GetProject() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+		}
+		if req.Msg.GetTyped().GetName() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+		}
+		policy = v1alpha1.PromotionPolicy{
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: req.Msg.GetTyped().GetProject(),
+				Name:      req.Msg.GetTyped().GetName(),
+			},
+			Stage:               req.Msg.GetTyped().GetStage(),
+			EnableAutoPromotion: req.Msg.GetTyped().GetEnableAutoPromotion(),
+		}
+	default:
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("promotion_policy should not be empty"))
+	}
+
+	if err := s.validateProject(ctx, policy.GetNamespace()); err != nil {
+		return nil, err
+	}
+	if err := s.client.Create(ctx, &policy); err != nil {
+		if kubeerr.IsAlreadyExists(err) {
+			return nil, connect.NewError(connect.CodeAlreadyExists, err)
+		}
+		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create promotion policy"))
+	}
+	return connect.NewResponse(&svcv1alpha1.CreatePromotionPolicyResponse{
+		PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
+	}), nil
+}
diff --git a/internal/api/create_stage_v1alpha1.go b/internal/api/create_stage_v1alpha1.go
new file mode 100644
index 00000000..c7c34f40
--- /dev/null
+++ b/internal/api/create_stage_v1alpha1.go
@@ -0,0 +1,57 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/yaml"
+
+	"github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) CreateStage(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.CreateStageRequest],
+) (*connect.Response[svcv1alpha1.CreateStageResponse], error) {
+	var stage v1alpha1.Stage
+	switch {
+	case req.Msg.GetYaml() != "":
+		if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &stage); err != nil {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
+		}
+	case req.Msg.GetTyped() != nil:
+		if req.Msg.GetTyped().GetProject() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+		}
+		if req.Msg.GetTyped().GetName() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+		}
+		stage = v1alpha1.Stage{
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: req.Msg.GetTyped().GetProject(),
+				Name:      req.Msg.GetTyped().GetName(),
+			},
+			Spec: typesv1alpha1.FromStageSpecProto(req.Msg.GetTyped().GetSpec()),
+		}
+	default:
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("stage should not be empty"))
+	}
+
+	if err := s.validateProject(ctx, stage.GetNamespace()); err != nil {
+		return nil, err
+	}
+	if err := s.client.Create(ctx, &stage); err != nil {
+		if kubeerr.IsAlreadyExists(err) {
+			return nil, connect.NewError(connect.CodeAlreadyExists, err)
+		}
+		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create stage"))
+	}
+	return connect.NewResponse(&svcv1alpha1.CreateStageResponse{
+		Stage: typesv1alpha1.ToStageProto(stage),
+	}), nil
+}
diff --git a/internal/api/delete_project_v1alpha1.go b/internal/api/delete_project_v1alpha1.go
new file mode 100644
index 00000000..8ed3319a
--- /dev/null
+++ b/internal/api/delete_project_v1alpha1.go
@@ -0,0 +1,44 @@
+package api
+
+import (
+	"context"
+	"strings"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	corev1 "k8s.io/api/core/v1"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/akuity/kargo/api/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) DeleteProject(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.DeleteProjectRequest],
+) (*connect.Response[svcv1alpha1.DeleteProjectResponse], error) {
+	name := strings.TrimSpace(req.Msg.GetName())
+	if name == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+	}
+
+	var ns corev1.Namespace
+	if err := s.client.Get(ctx, client.ObjectKey{Name: name}, &ns); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound,
+				errors.Errorf("project %q not found", name))
+		}
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	if ns.GetLabels()[v1alpha1.LabelProjectKey] != v1alpha1.LabelTrueValue {
+		return nil, connect.NewError(connect.CodeFailedPrecondition,
+			errors.Errorf("namespace %q is not a project", ns.GetName()))
+	}
+	if err := s.client.Delete(ctx, &ns); err != nil && !kubeerr.IsNotFound(err) {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.DeleteProjectResponse{
+		/* explicitly empty */
+	}), nil
+}
diff --git a/internal/api/delete_promotion_policy_v1alpha1.go b/internal/api/delete_promotion_policy_v1alpha1.go
new file mode 100644
index 00000000..d0fb7877
--- /dev/null
+++ b/internal/api/delete_promotion_policy_v1alpha1.go
@@ -0,0 +1,46 @@
+package api
+
+import (
+	"context"
+	"fmt"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kargov1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) DeletePromotionPolicy(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.DeletePromotionPolicyRequest],
+) (*connect.Response[svcv1alpha1.DeletePromotionPolicyResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if req.Msg.GetName() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var policy kargov1alpha1.PromotionPolicy
+	key := client.ObjectKey{
+		Namespace: req.Msg.GetProject(),
+		Name:      req.Msg.GetName(),
+	}
+	if err := s.client.Get(ctx, key, &policy); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound,
+				fmt.Errorf("promotion policy %q not found", key.String()))
+		}
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	if err := s.client.Delete(ctx, &policy); err != nil && !kubeerr.IsNotFound(err) {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.DeletePromotionPolicyResponse{}), nil
+}
diff --git a/internal/api/delete_stage_v1alpha1.go b/internal/api/delete_stage_v1alpha1.go
new file mode 100644
index 00000000..aff1ea48
--- /dev/null
+++ b/internal/api/delete_stage_v1alpha1.go
@@ -0,0 +1,46 @@
+package api
+
+import (
+	"context"
+	"fmt"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) DeleteStage(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.DeleteStageRequest],
+) (*connect.Response[svcv1alpha1.DeleteStageResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if req.Msg.GetName() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var stage kargoapi.Stage
+	key := client.ObjectKey{
+		Namespace: req.Msg.GetProject(),
+		Name:      req.Msg.GetName(),
+	}
+	if err := s.client.Get(ctx, key, &stage); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound,
+				fmt.Errorf("stage %q not found", key.String()))
+		}
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	if err := s.client.Delete(ctx, &stage); err != nil && !kubeerr.IsNotFound(err) {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.DeleteStageResponse{}), nil
+}
diff --git a/internal/api/get_promotion_policy_v1alpha1.go b/internal/api/get_promotion_policy_v1alpha1.go
new file mode 100644
index 00000000..6316b68e
--- /dev/null
+++ b/internal/api/get_promotion_policy_v1alpha1.go
@@ -0,0 +1,43 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) GetPromotionPolicy(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.GetPromotionPolicyRequest],
+) (*connect.Response[svcv1alpha1.GetPromotionPolicyResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if req.Msg.GetName() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var policy kubev1alpha1.PromotionPolicy
+	if err := s.client.Get(ctx, client.ObjectKey{
+		Namespace: req.Msg.GetProject(),
+		Name:      req.Msg.GetName(),
+	}, &policy); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound, err)
+		}
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.GetPromotionPolicyResponse{
+		PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
+	}), nil
+}
diff --git a/internal/api/get_public_config_v1alpha1.go b/internal/api/get_public_config_v1alpha1.go
new file mode 100644
index 00000000..e08a012f
--- /dev/null
+++ b/internal/api/get_public_config_v1alpha1.go
@@ -0,0 +1,28 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) GetPublicConfig(
+	context.Context,
+	*connect.Request[svcv1alpha1.GetPublicConfigRequest],
+) (*connect.Response[svcv1alpha1.GetPublicConfigResponse], error) {
+	var oidcCfg *svcv1alpha1.OIDCConfig
+	if s.cfg.OIDCConfig != nil {
+		oidcCfg = &svcv1alpha1.OIDCConfig{
+			IssuerUrl: s.cfg.OIDCConfig.IssuerURL,
+			ClientId:  s.cfg.OIDCConfig.ClientID,
+			Scopes:    s.cfg.OIDCConfig.Scopes,
+		}
+	}
+	resp := &svcv1alpha1.GetPublicConfigResponse{
+		AdminAccountEnabled: s.cfg.AdminConfig != nil,
+		OidcConfig:          oidcCfg,
+	}
+	return connect.NewResponse(resp), nil
+}
diff --git a/internal/api/get_stage_v1alpha1.go b/internal/api/get_stage_v1alpha1.go
new file mode 100644
index 00000000..3ddd6310
--- /dev/null
+++ b/internal/api/get_stage_v1alpha1.go
@@ -0,0 +1,43 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) GetStage(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.GetStageRequest],
+) (*connect.Response[svcv1alpha1.GetStageResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if req.Msg.GetName() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var stage kubev1alpha1.Stage
+	if err := s.client.Get(ctx, client.ObjectKey{
+		Namespace: req.Msg.GetProject(),
+		Name:      req.Msg.GetName(),
+	}, &stage); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound, err)
+		}
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.GetStageResponse{
+		Stage: typesv1alpha1.ToStageProto(stage),
+	}), nil
+}
diff --git a/internal/api/get_version_info_v1alpha1.go b/internal/api/get_version_info_v1alpha1.go
new file mode 100644
index 00000000..8abc2fb0
--- /dev/null
+++ b/internal/api/get_version_info_v1alpha1.go
@@ -0,0 +1,22 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	"github.com/akuity/kargo/internal/version"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) GetVersionInfo(
+	context.Context,
+	*connect.Request[svcv1alpha1.GetVersionInfoRequest],
+) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
+	return connect.NewResponse(
+		&svcv1alpha1.GetVersionInfoResponse{
+			VersionInfo: typesv1alpha1.ToVersionProto(version.GetVersion()),
+		},
+	), nil
+}
diff --git a/internal/api/handler/admin_login_v1alpha1.go b/internal/api/handler/admin_login_v1alpha1.go
deleted file mode 100644
index cbe7701a..00000000
--- a/internal/api/handler/admin_login_v1alpha1.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package handler
-
-import (
-	"context"
-	"time"
-
-	"connectrpc.com/connect"
-	"github.com/golang-jwt/jwt/v5"
-	"github.com/pkg/errors"
-	uuid "github.com/satori/go.uuid"
-	"golang.org/x/crypto/bcrypt"
-
-	"github.com/akuity/kargo/internal/api/config"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-func init() {
-	jwt.MarshalSingleStringAsArray = false
-}
-
-type AdminLoginV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.AdminLoginRequest],
-) (*connect.Response[svcv1alpha1.AdminLoginResponse], error)
-
-func AdminLoginV1Alpha1(cfg *config.AdminConfig) AdminLoginV1Alpha1Func {
-	return func(
-		_ context.Context,
-		req *connect.Request[svcv1alpha1.AdminLoginRequest],
-	) (*connect.Response[svcv1alpha1.AdminLoginResponse], error) {
-		if cfg == nil {
-			return nil, connect.NewError(
-				connect.CodePermissionDenied,
-				errors.New("admin user is not enabled"),
-			)
-		}
-
-		if err := bcrypt.CompareHashAndPassword(
-			[]byte(cfg.HashedPassword),
-			[]byte(req.Msg.Password),
-		); err != nil {
-			return nil, connect.NewError(
-				connect.CodePermissionDenied,
-				errors.New("invalid password"),
-			)
-		}
-
-		now := time.Now()
-		idToken := jwt.NewWithClaims(
-			jwt.SigningMethodHS256,
-			jwt.RegisteredClaims{
-				IssuedAt:  jwt.NewNumericDate(now),
-				Issuer:    cfg.TokenIssuer,
-				Audience:  []string{cfg.TokenAudience},
-				NotBefore: jwt.NewNumericDate(now),
-				Subject:   "admin",
-				ID:        uuid.NewV4().String(),
-				ExpiresAt: jwt.NewNumericDate(now.Add(cfg.TokenTTL)),
-			},
-		)
-
-		signedToken, err := idToken.SignedString(cfg.TokenSigningKey)
-		if err != nil {
-			return nil, connect.NewError(
-				connect.CodeInternal,
-				errors.Wrap(err, "error signing ID token"),
-			)
-		}
-
-		return connect.NewResponse(&svcv1alpha1.AdminLoginResponse{
-			IdToken: signedToken,
-		}), nil
-	}
-}
diff --git a/internal/api/handler/create_project_v1alpha1.go b/internal/api/handler/create_project_v1alpha1.go
deleted file mode 100644
index 226f9e2c..00000000
--- a/internal/api/handler/create_project_v1alpha1.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package handler
-
-import (
-	"context"
-	"strings"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	"google.golang.org/protobuf/types/known/timestamppb"
-	corev1 "k8s.io/api/core/v1"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	"github.com/akuity/kargo/api/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type CreateProjectV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.CreateProjectRequest],
-) (*connect.Response[svcv1alpha1.CreateProjectResponse], error)
-
-func CreateProjectV1Alpha1(
-	kc client.Client,
-) CreateProjectV1Alpha1Func {
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.CreateProjectRequest],
-	) (*connect.Response[svcv1alpha1.CreateProjectResponse], error) {
-		name := strings.TrimSpace(req.Msg.GetName())
-		if name == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-		}
-
-		var existingNs corev1.Namespace
-		if err := kc.Get(ctx, client.ObjectKey{Name: name}, &existingNs); err == nil || !kubeerr.IsNotFound(err) {
-			if err != nil {
-				return nil, connect.NewError(connect.CodeInternal,
-					errors.Wrap(err, "get existing namespace"))
-			}
-			if existingNs.GetLabels()[v1alpha1.LabelProjectKey] == v1alpha1.LabelTrueValue {
-				return nil, connect.NewError(connect.CodeAlreadyExists,
-					errors.Errorf("project %q already exists", name))
-			}
-			return nil, connect.NewError(connect.CodeFailedPrecondition,
-				errors.Errorf("non-project namespace %q already exists", name))
-		}
-
-		ns := corev1.Namespace{
-			ObjectMeta: metav1.ObjectMeta{
-				Labels: map[string]string{
-					v1alpha1.LabelProjectKey: v1alpha1.LabelTrueValue,
-				},
-				Name: name,
-			},
-		}
-		if err := kc.Create(ctx, &ns); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.CreateProjectResponse{
-			Project: &svcv1alpha1.Project{
-				Name:       ns.Name,
-				CreateTime: timestamppb.New(ns.CreationTimestamp.Time),
-			},
-		}), nil
-	}
-}
diff --git a/internal/api/handler/create_promotion_policy_v1alpha1.go b/internal/api/handler/create_promotion_policy_v1alpha1.go
deleted file mode 100644
index a3ee5e3a..00000000
--- a/internal/api/handler/create_promotion_policy_v1alpha1.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/yaml"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	"github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type CreatePromotionPolicyV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.CreatePromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.CreatePromotionPolicyResponse], error)
-
-func CreatePromotionPolicyV1Alpha1(
-	kc client.Client,
-) CreatePromotionPolicyV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.CreatePromotionPolicyRequest],
-	) (*connect.Response[svcv1alpha1.CreatePromotionPolicyResponse], error) {
-		var policy v1alpha1.PromotionPolicy
-		switch {
-		case req.Msg.GetYaml() != "":
-			if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &policy); err != nil {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
-			}
-		case req.Msg.GetTyped() != nil:
-			if req.Msg.GetTyped().GetProject() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-			}
-			if req.Msg.GetTyped().GetName() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-			}
-			policy = v1alpha1.PromotionPolicy{
-				ObjectMeta: metav1.ObjectMeta{
-					Namespace: req.Msg.GetTyped().GetProject(),
-					Name:      req.Msg.GetTyped().GetName(),
-				},
-				Stage:               req.Msg.GetTyped().GetStage(),
-				EnableAutoPromotion: req.Msg.GetTyped().GetEnableAutoPromotion(),
-			}
-		default:
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("promotion_policy should not be empty"))
-		}
-
-		if err := validateProject(ctx, policy.GetNamespace()); err != nil {
-			return nil, err
-		}
-		if err := kc.Create(ctx, &policy); err != nil {
-			if kubeerr.IsAlreadyExists(err) {
-				return nil, connect.NewError(connect.CodeAlreadyExists, err)
-			}
-			return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create promotion policy"))
-		}
-		return connect.NewResponse(&svcv1alpha1.CreatePromotionPolicyResponse{
-			PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/create_stage_v1alpha1.go b/internal/api/handler/create_stage_v1alpha1.go
deleted file mode 100644
index 00b49f04..00000000
--- a/internal/api/handler/create_stage_v1alpha1.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/yaml"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	"github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type CreateStageV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.CreateStageRequest],
-) (*connect.Response[svcv1alpha1.CreateStageResponse], error)
-
-func CreateStageV1Alpha1(
-	kc client.Client,
-) CreateStageV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.CreateStageRequest],
-	) (*connect.Response[svcv1alpha1.CreateStageResponse], error) {
-		var stage v1alpha1.Stage
-		switch {
-		case req.Msg.GetYaml() != "":
-			if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &stage); err != nil {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
-			}
-		case req.Msg.GetTyped() != nil:
-			if req.Msg.GetTyped().GetProject() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-			}
-			if req.Msg.GetTyped().GetName() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-			}
-			stage = v1alpha1.Stage{
-				ObjectMeta: metav1.ObjectMeta{
-					Namespace: req.Msg.GetTyped().GetProject(),
-					Name:      req.Msg.GetTyped().GetName(),
-				},
-				Spec: typesv1alpha1.FromStageSpecProto(req.Msg.GetTyped().GetSpec()),
-			}
-		default:
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("stage should not be empty"))
-		}
-
-		if err := validateProject(ctx, stage.GetNamespace()); err != nil {
-			return nil, err
-		}
-		if err := kc.Create(ctx, &stage); err != nil {
-			if kubeerr.IsAlreadyExists(err) {
-				return nil, connect.NewError(connect.CodeAlreadyExists, err)
-			}
-			return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create stage"))
-		}
-		return connect.NewResponse(&svcv1alpha1.CreateStageResponse{
-			Stage: typesv1alpha1.ToStageProto(stage),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/delete_project_v1alpha1.go b/internal/api/handler/delete_project_v1alpha1.go
deleted file mode 100644
index 41421c14..00000000
--- a/internal/api/handler/delete_project_v1alpha1.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package handler
-
-import (
-	"context"
-	"strings"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	corev1 "k8s.io/api/core/v1"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	"github.com/akuity/kargo/api/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type DeleteProjectV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.DeleteProjectRequest],
-) (*connect.Response[svcv1alpha1.DeleteProjectResponse], error)
-
-func DeleteProjectV1Alpha1(
-	kc client.Client,
-) DeleteProjectV1Alpha1Func {
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.DeleteProjectRequest],
-	) (*connect.Response[svcv1alpha1.DeleteProjectResponse], error) {
-		name := strings.TrimSpace(req.Msg.GetName())
-		if name == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-		}
-
-		var ns corev1.Namespace
-		if err := kc.Get(ctx, client.ObjectKey{Name: name}, &ns); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound,
-					errors.Errorf("project %q not found", name))
-			}
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		if ns.GetLabels()[v1alpha1.LabelProjectKey] != v1alpha1.LabelTrueValue {
-			return nil, connect.NewError(connect.CodeFailedPrecondition,
-				errors.Errorf("namespace %q is not a project", ns.GetName()))
-		}
-		if err := kc.Delete(ctx, &ns); err != nil && !kubeerr.IsNotFound(err) {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.DeleteProjectResponse{
-			/* explicitly empty */
-		}), nil
-	}
-}
diff --git a/internal/api/handler/delete_promotion_policy_v1alpha1.go b/internal/api/handler/delete_promotion_policy_v1alpha1.go
deleted file mode 100644
index 1ce88d6e..00000000
--- a/internal/api/handler/delete_promotion_policy_v1alpha1.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package handler
-
-import (
-	"context"
-	"fmt"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kargov1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type DeletePromotionPolicyV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.DeletePromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.DeletePromotionPolicyResponse], error)
-
-func DeletePromotionPolicyV1Alpha1(
-	kc client.Client,
-) DeletePromotionPolicyV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.DeletePromotionPolicyRequest],
-	) (*connect.Response[svcv1alpha1.DeletePromotionPolicyResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if req.Msg.GetName() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var policy kargov1alpha1.PromotionPolicy
-		key := client.ObjectKey{
-			Namespace: req.Msg.GetProject(),
-			Name:      req.Msg.GetName(),
-		}
-		if err := kc.Get(ctx, key, &policy); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound,
-					fmt.Errorf("promotion policy %q not found", key.String()))
-			}
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		if err := kc.Delete(ctx, &policy); err != nil && !kubeerr.IsNotFound(err) {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.DeletePromotionPolicyResponse{}), nil
-	}
-}
diff --git a/internal/api/handler/delete_stage_v1alpha1.go b/internal/api/handler/delete_stage_v1alpha1.go
deleted file mode 100644
index 65048e32..00000000
--- a/internal/api/handler/delete_stage_v1alpha1.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package handler
-
-import (
-	"context"
-	"fmt"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type DeleteStageV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.DeleteStageRequest],
-) (*connect.Response[svcv1alpha1.DeleteStageResponse], error)
-
-func DeleteStageV1Alpha1(
-	kc client.Client,
-) DeleteStageV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.DeleteStageRequest],
-	) (*connect.Response[svcv1alpha1.DeleteStageResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if req.Msg.GetName() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var stage kargoapi.Stage
-		key := client.ObjectKey{
-			Namespace: req.Msg.GetProject(),
-			Name:      req.Msg.GetName(),
-		}
-		if err := kc.Get(ctx, key, &stage); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound,
-					fmt.Errorf("stage %q not found", key.String()))
-			}
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		if err := kc.Delete(ctx, &stage); err != nil && !kubeerr.IsNotFound(err) {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.DeleteStageResponse{}), nil
-	}
-}
diff --git a/internal/api/handler/get_promotion_policy_v1alpha1.go b/internal/api/handler/get_promotion_policy_v1alpha1.go
deleted file mode 100644
index d9c575f0..00000000
--- a/internal/api/handler/get_promotion_policy_v1alpha1.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type GetPromotionPolicyV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.GetPromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.GetPromotionPolicyResponse], error)
-
-func GetPromotionPolicyV1Alpha1(
-	kc client.Client,
-) GetPromotionPolicyV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.GetPromotionPolicyRequest],
-	) (*connect.Response[svcv1alpha1.GetPromotionPolicyResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if req.Msg.GetName() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var policy kubev1alpha1.PromotionPolicy
-		if err := kc.Get(ctx, client.ObjectKey{
-			Namespace: req.Msg.GetProject(),
-			Name:      req.Msg.GetName(),
-		}, &policy); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound, err)
-			}
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.GetPromotionPolicyResponse{
-			PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/get_public_config_v1alpha1.go b/internal/api/handler/get_public_config_v1alpha1.go
deleted file mode 100644
index 8b597b9d..00000000
--- a/internal/api/handler/get_public_config_v1alpha1.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-
-	"github.com/akuity/kargo/internal/api/config"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type GetPublicConfigV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.GetPublicConfigRequest],
-) (*connect.Response[svcv1alpha1.GetPublicConfigResponse], error)
-
-func GetPublicConfigV1Alpha1(
-	cfg config.ServerConfig,
-) GetPublicConfigV1Alpha1Func {
-	var oidcCfg *svcv1alpha1.OIDCConfig
-	if cfg.OIDCConfig != nil {
-		oidcCfg = &svcv1alpha1.OIDCConfig{
-			IssuerUrl: cfg.OIDCConfig.IssuerURL,
-			ClientId:  cfg.OIDCConfig.ClientID,
-			Scopes:    cfg.OIDCConfig.Scopes,
-		}
-	}
-	resp := &svcv1alpha1.GetPublicConfigResponse{
-		AdminAccountEnabled: cfg.AdminConfig != nil,
-		OidcConfig:          oidcCfg,
-	}
-	return func(
-		_ context.Context,
-		_ *connect.Request[svcv1alpha1.GetPublicConfigRequest],
-	) (*connect.Response[svcv1alpha1.GetPublicConfigResponse], error) {
-		return connect.NewResponse(resp), nil
-	}
-}
diff --git a/internal/api/handler/get_stage_v1alpha1.go b/internal/api/handler/get_stage_v1alpha1.go
deleted file mode 100644
index dd7d4b8e..00000000
--- a/internal/api/handler/get_stage_v1alpha1.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type GetStageV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.GetStageRequest],
-) (*connect.Response[svcv1alpha1.GetStageResponse], error)
-
-func GetStageV1Alpha1(
-	kc client.Client,
-) GetStageV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.GetStageRequest],
-	) (*connect.Response[svcv1alpha1.GetStageResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if req.Msg.GetName() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var stage kubev1alpha1.Stage
-		if err := kc.Get(ctx, client.ObjectKey{
-			Namespace: req.Msg.GetProject(),
-			Name:      req.Msg.GetName(),
-		}, &stage); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound, err)
-			}
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.GetStageResponse{
-			Stage: typesv1alpha1.ToStageProto(stage),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/get_version_info_v1alpha1.go b/internal/api/handler/get_version_info_v1alpha1.go
deleted file mode 100644
index 7fa4d077..00000000
--- a/internal/api/handler/get_version_info_v1alpha1.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	"github.com/akuity/kargo/internal/version"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type GetVersionInfoV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.GetVersionInfoRequest],
-) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error)
-
-func GetVersionInfoV1Alpha1(v version.Version) GetVersionInfoV1Alpha1Func {
-	resp := &svcv1alpha1.GetVersionInfoResponse{
-		VersionInfo: typesv1alpha1.ToVersionProto(v),
-	}
-	return func(
-		_ context.Context,
-		_ *connect.Request[svcv1alpha1.GetVersionInfoRequest],
-	) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
-		return connect.NewResponse(resp), nil
-	}
-}
diff --git a/internal/api/handler/list_projects_v1alpha1.go b/internal/api/handler/list_projects_v1alpha1.go
deleted file mode 100644
index b2417d9a..00000000
--- a/internal/api/handler/list_projects_v1alpha1.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"google.golang.org/protobuf/types/known/timestamppb"
-	corev1 "k8s.io/api/core/v1"
-	"k8s.io/apimachinery/pkg/labels"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	"github.com/akuity/kargo/api/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type ListProjectsV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.ListProjectsRequest],
-) (*connect.Response[svcv1alpha1.ListProjectsResponse], error)
-
-func ListProjectsV1Alpha1(
-	kc client.Client,
-) ListProjectsV1Alpha1Func {
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.ListProjectsRequest],
-	) (*connect.Response[svcv1alpha1.ListProjectsResponse], error) {
-		// Only list namespaces which are labeled as Kargo projects
-		selector := labels.Set{v1alpha1.LabelProjectKey: v1alpha1.LabelTrueValue}.AsSelector()
-		nsList := &corev1.NamespaceList{}
-		if err := kc.List(ctx, nsList, client.MatchingLabelsSelector{Selector: selector}); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-
-		var projects []*svcv1alpha1.Project
-		for _, ns := range nsList.Items {
-			projects = append(projects, &svcv1alpha1.Project{
-				Name:       ns.Name,
-				CreateTime: timestamppb.New(ns.CreationTimestamp.Time),
-			})
-		}
-
-		return connect.NewResponse(&svcv1alpha1.ListProjectsResponse{
-			Projects: projects,
-		}), nil
-	}
-}
diff --git a/internal/api/handler/list_promotion_policies_v1alpha1.go b/internal/api/handler/list_promotion_policies_v1alpha1.go
deleted file mode 100644
index 34c40793..00000000
--- a/internal/api/handler/list_promotion_policies_v1alpha1.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-	"github.com/akuity/kargo/pkg/api/v1alpha1"
-)
-
-type ListPromotionPoliciesV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.ListPromotionPoliciesRequest],
-) (*connect.Response[svcv1alpha1.ListPromotionPoliciesResponse], error)
-
-func ListPromotionPoliciesV1Alpha1(
-	kc client.Client,
-) ListPromotionPoliciesV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.ListPromotionPoliciesRequest],
-	) (*connect.Response[svcv1alpha1.ListPromotionPoliciesResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var list kubev1alpha1.PromotionPolicyList
-		if err := kc.List(ctx, &list, client.InNamespace(req.Msg.GetProject())); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		policies := make([]*v1alpha1.PromotionPolicy, len(list.Items))
-		for idx, policy := range list.Items {
-			policies[idx] = typesv1alpha1.ToPromotionPolicyProto(policy)
-		}
-		return connect.NewResponse(&svcv1alpha1.ListPromotionPoliciesResponse{
-			PromotionPolicies: policies,
-		}), nil
-	}
-}
diff --git a/internal/api/handler/list_stages_v1alpha1.go b/internal/api/handler/list_stages_v1alpha1.go
deleted file mode 100644
index c6d753d9..00000000
--- a/internal/api/handler/list_stages_v1alpha1.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-	"github.com/akuity/kargo/pkg/api/v1alpha1"
-)
-
-type ListStagesV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.ListStagesRequest],
-) (*connect.Response[svcv1alpha1.ListStagesResponse], error)
-
-func ListStagesV1Alpha1(
-	kc client.Client,
-) ListStagesV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.ListStagesRequest],
-	) (*connect.Response[svcv1alpha1.ListStagesResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var list kubev1alpha1.StageList
-		if err := kc.List(ctx, &list, client.InNamespace(req.Msg.GetProject())); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-
-		stages := make([]*v1alpha1.Stage, len(list.Items))
-		for idx := range list.Items {
-			stages[idx] = typesv1alpha1.ToStageProto(list.Items[idx])
-		}
-		return connect.NewResponse(&svcv1alpha1.ListStagesResponse{
-			Stages: stages,
-		}), nil
-	}
-}
diff --git a/internal/api/handler/promote_stage_v1alpha1.go b/internal/api/handler/promote_stage_v1alpha1.go
deleted file mode 100644
index 88ef5067..00000000
--- a/internal/api/handler/promote_stage_v1alpha1.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package handler
-
-import (
-	"context"
-	"fmt"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/utils/pointer"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type PromoteStageV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.PromoteStageRequest],
-) (*connect.Response[svcv1alpha1.PromoteStageResponse], error)
-
-func PromoteStageV1Alpha1(
-	kc client.Client,
-) PromoteStageV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.PromoteStageRequest],
-	) (*connect.Response[svcv1alpha1.PromoteStageResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if req.Msg.GetState() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("state should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var stage kubev1alpha1.Stage
-		if err := kc.Get(ctx, client.ObjectKey{
-			Namespace: req.Msg.GetProject(),
-			Name:      req.Msg.GetName(),
-		}, &stage); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound,
-					fmt.Errorf("stage %q not found", req.Msg.GetName()))
-			}
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-
-		stateExists := false
-		for _, state := range stage.Status.AvailableStates {
-			if req.Msg.GetState() == state.ID {
-				stateExists = true
-				break
-			}
-		}
-		if !stateExists {
-			return nil, connect.NewError(connect.CodeNotFound,
-				fmt.Errorf("state %q not found in Stage %q", req.Msg.GetState(), stage.Name))
-		}
-
-		promotion := &kubev1alpha1.Promotion{
-			ObjectMeta: metav1.ObjectMeta{
-				GenerateName: fmt.Sprintf("%s-", req.Msg.GetName()),
-				Namespace:    req.Msg.GetProject(),
-				OwnerReferences: []metav1.OwnerReference{
-					{
-						APIVersion:         kubev1alpha1.GroupVersion.String(),
-						Kind:               "Stage",
-						Name:               stage.Name,
-						UID:                stage.UID,
-						BlockOwnerDeletion: pointer.Bool(true),
-					},
-				},
-			},
-			Spec: &kubev1alpha1.PromotionSpec{
-				Stage: stage.Name,
-				State: req.Msg.GetState(),
-			},
-		}
-		if err := kc.Create(ctx, promotion); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.PromoteStageResponse{
-			Promotion: typesv1alpha1.ToPromotionProto(*promotion),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/set_auto_promotion_for_stage_v1alpha1.go b/internal/api/handler/set_auto_promotion_for_stage_v1alpha1.go
deleted file mode 100644
index 28541526..00000000
--- a/internal/api/handler/set_auto_promotion_for_stage_v1alpha1.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	"github.com/akuity/kargo/internal/kubeclient"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type SetAutoPromotionForStageV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.SetAutoPromotionForStageRequest],
-) (*connect.Response[svcv1alpha1.SetAutoPromotionForStageResponse], error)
-
-func SetAutoPromotionForStageV1Alpha1(
-	kc client.Client,
-) SetAutoPromotionForStageV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.SetAutoPromotionForStageRequest],
-	) (*connect.Response[svcv1alpha1.SetAutoPromotionForStageResponse], error) {
-		if req.Msg.GetProject() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if req.Msg.GetStage() == "" {
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("stage should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return nil, err
-		}
-
-		var policyList kubev1alpha1.PromotionPolicyList
-		if err := kc.List(ctx, &policyList, client.InNamespace(req.Msg.GetProject()), client.MatchingFields{
-			kubeclient.PromotionPoliciesByStageIndexField: req.Msg.GetStage(),
-		}); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "list promotion policies"))
-		}
-
-		// Since only one PromotionPolicy is allowed per stage,
-		// create if not exists and update if exists.
-		var policy kubev1alpha1.PromotionPolicy
-		if len(policyList.Items) > 0 {
-			policy = policyList.Items[0]
-			policy.EnableAutoPromotion = req.Msg.GetEnable()
-			if err := kc.Update(ctx, &policy); err != nil {
-				return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "update promotion policy"))
-			}
-		} else {
-			policy = kubev1alpha1.PromotionPolicy{
-				ObjectMeta: metav1.ObjectMeta{
-					Namespace: req.Msg.GetProject(),
-					Name:      req.Msg.GetStage(),
-				},
-				Stage:               req.Msg.GetStage(),
-				EnableAutoPromotion: req.Msg.GetEnable(),
-			}
-			if err := kc.Create(ctx, &policy); err != nil {
-				return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create promotion policy"))
-			}
-		}
-		return connect.NewResponse(&svcv1alpha1.SetAutoPromotionForStageResponse{
-			PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/update_promotion_policy_v1alpha1.go b/internal/api/handler/update_promotion_policy_v1alpha1.go
deleted file mode 100644
index 7040c1d3..00000000
--- a/internal/api/handler/update_promotion_policy_v1alpha1.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/yaml"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type UpdatePromotionPolicyV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.UpdatePromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.UpdatePromotionPolicyResponse], error)
-
-func UpdatePromotionPolicyV1Alpha1(
-	kc client.Client,
-) UpdatePromotionPolicyV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.UpdatePromotionPolicyRequest],
-	) (*connect.Response[svcv1alpha1.UpdatePromotionPolicyResponse], error) {
-		var policy kubev1alpha1.PromotionPolicy
-		switch {
-		case req.Msg.GetYaml() != "":
-			if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &policy); err != nil {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
-			}
-		case req.Msg.GetTyped() != nil:
-			if req.Msg.GetTyped().GetProject() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-			}
-			if req.Msg.GetTyped().GetName() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-			}
-			policy = kubev1alpha1.PromotionPolicy{
-				ObjectMeta: metav1.ObjectMeta{
-					Namespace: req.Msg.GetTyped().GetProject(),
-					Name:      req.Msg.GetTyped().GetName(),
-				},
-				Stage:               req.Msg.GetTyped().GetStage(),
-				EnableAutoPromotion: req.Msg.GetTyped().GetEnableAutoPromotion(),
-			}
-		default:
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("promotion_policy should not be empty"))
-		}
-
-		if err := validateProject(ctx, policy.GetNamespace()); err != nil {
-			return nil, err
-		}
-		var existingPolicy kubev1alpha1.PromotionPolicy
-		if err := kc.Get(ctx, client.ObjectKeyFromObject(&policy), &existingPolicy); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound, err)
-			}
-		}
-		policy.SetResourceVersion(existingPolicy.GetResourceVersion())
-		if err := kc.Update(ctx, &policy); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.UpdatePromotionPolicyResponse{
-			PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/update_stage_v1alpha1.go b/internal/api/handler/update_stage_v1alpha1.go
deleted file mode 100644
index d5b32721..00000000
--- a/internal/api/handler/update_stage_v1alpha1.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/yaml"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type UpdateStageV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.UpdateStageRequest],
-) (*connect.Response[svcv1alpha1.UpdateStageResponse], error)
-
-func UpdateStageV1Alpha1(
-	kc client.Client,
-) UpdateStageV1Alpha1Func {
-	validateProject := newProjectValidator(kc)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.UpdateStageRequest],
-	) (*connect.Response[svcv1alpha1.UpdateStageResponse], error) {
-		var stage kubev1alpha1.Stage
-		switch {
-		case req.Msg.GetYaml() != "":
-			if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &stage); err != nil {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
-			}
-		case req.Msg.GetTyped() != nil:
-			if req.Msg.GetTyped().GetProject() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-			}
-			if req.Msg.GetTyped().GetName() == "" {
-				return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
-			}
-			stage = kubev1alpha1.Stage{
-				ObjectMeta: metav1.ObjectMeta{
-					Namespace: req.Msg.GetTyped().GetProject(),
-					Name:      req.Msg.GetTyped().GetName(),
-				},
-				Spec: typesv1alpha1.FromStageSpecProto(req.Msg.GetTyped().GetSpec()),
-			}
-		default:
-			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("stage should not be empty"))
-		}
-
-		if err := validateProject(ctx, stage.GetNamespace()); err != nil {
-			return nil, err
-		}
-		var existingStage kubev1alpha1.Stage
-		if err := kc.Get(ctx, client.ObjectKeyFromObject(&stage), &existingStage); err != nil {
-			if kubeerr.IsNotFound(err) {
-				return nil, connect.NewError(connect.CodeNotFound, err)
-			}
-		}
-		stage.SetResourceVersion(existingStage.GetResourceVersion())
-		if err := kc.Update(ctx, &stage); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
-		}
-		return connect.NewResponse(&svcv1alpha1.UpdateStageResponse{
-			Stage: typesv1alpha1.ToStageProto(stage),
-		}), nil
-	}
-}
diff --git a/internal/api/handler/validators.go b/internal/api/handler/validators.go
deleted file mode 100644
index 082189cf..00000000
--- a/internal/api/handler/validators.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	"k8s.io/apimachinery/pkg/util/validation/field"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	"github.com/akuity/kargo/internal/api/validation"
-)
-
-type projectValidatorFunc func(ctx context.Context, project string) error
-
-func newProjectValidator(kc client.Client) projectValidatorFunc {
-	return func(ctx context.Context, project string) error {
-		if err := validation.ValidateProject(ctx, kc, project); err != nil {
-			if errors.Is(err, validation.ErrProjectNotFound) {
-				return connect.NewError(connect.CodeNotFound, err)
-			}
-			var fieldErr *field.Error
-			if ok := errors.As(err, &fieldErr); ok {
-				return connect.NewError(connect.CodeInvalidArgument, err)
-			}
-			return connect.NewError(connect.CodeInternal, err)
-		}
-		return nil
-	}
-}
diff --git a/internal/api/handler/watch_stages_v1alpha1.go b/internal/api/handler/watch_stages_v1alpha1.go
deleted file mode 100644
index 85e2d639..00000000
--- a/internal/api/handler/watch_stages_v1alpha1.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package handler
-
-import (
-	"context"
-
-	"connectrpc.com/connect"
-	"github.com/pkg/errors"
-	kubeerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-	"k8s.io/apimachinery/pkg/fields"
-	"k8s.io/apimachinery/pkg/runtime"
-	libClient "sigs.k8s.io/controller-runtime/pkg/client"
-
-	kargov1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	"github.com/akuity/kargo/internal/api/kubernetes"
-	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-type WatchStageV1Alpha1Func func(
-	context.Context,
-	*connect.Request[svcv1alpha1.WatchStagesRequest],
-	*connect.ServerStream[svcv1alpha1.WatchStagesResponse],
-) error
-
-func WatchStageV1Alpha1(client kubernetes.Client) WatchStageV1Alpha1Func {
-	validateProject := newProjectValidator(client)
-	return func(
-		ctx context.Context,
-		req *connect.Request[svcv1alpha1.WatchStagesRequest],
-		stream *connect.ServerStream[svcv1alpha1.WatchStagesResponse],
-	) error {
-		if req.Msg.GetProject() == "" {
-			return connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
-		}
-		if err := validateProject(ctx, req.Msg.GetProject()); err != nil {
-			return err
-		}
-
-		if req.Msg.GetName() != "" {
-			if err := client.Get(ctx, libClient.ObjectKey{
-				Namespace: req.Msg.GetProject(),
-				Name:      req.Msg.GetName(),
-			}, &kargov1alpha1.Stage{}); err != nil {
-				if kubeerr.IsNotFound(err) {
-					return connect.NewError(connect.CodeNotFound, err)
-				}
-				return connect.NewError(connect.CodeInternal, err)
-			}
-		}
-
-		opts := metav1.ListOptions{}
-		if req.Msg.GetName() != "" {
-			opts.FieldSelector = fields.OneTermEqualSelector(metav1.ObjectNameField, req.Msg.GetName()).String()
-		}
-		w, err :=
-			client.Watch(ctx, &kargov1alpha1.Stage{}, req.Msg.GetProject(), opts)
-		if err != nil {
-			return errors.Wrap(err, "watch stage")
-		}
-		defer w.Stop()
-		for {
-			select {
-			case <-ctx.Done():
-				return ctx.Err()
-			case e, ok := <-w.ResultChan():
-				if !ok {
-					return nil
-				}
-				u, ok := e.Object.(*unstructured.Unstructured)
-				if !ok {
-					return errors.Errorf("unexpected object type %T", e.Object)
-				}
-				var stage *kargov1alpha1.Stage
-				if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &stage); err != nil {
-					return errors.Wrap(err, "from unstructured")
-				}
-				if err := stream.Send(&svcv1alpha1.WatchStagesResponse{
-					Stage: typesv1alpha1.ToStageProto(*stage),
-					Type:  string(e.Type),
-				}); err != nil {
-					return errors.Wrap(err, "send response")
-				}
-			}
-		}
-	}
-}
diff --git a/internal/api/list_projects_v1alpha1.go b/internal/api/list_projects_v1alpha1.go
new file mode 100644
index 00000000..e3812f62
--- /dev/null
+++ b/internal/api/list_projects_v1alpha1.go
@@ -0,0 +1,38 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"google.golang.org/protobuf/types/known/timestamppb"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/akuity/kargo/api/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) ListProjects(
+	ctx context.Context,
+	_ *connect.Request[svcv1alpha1.ListProjectsRequest],
+) (*connect.Response[svcv1alpha1.ListProjectsResponse], error) {
+	// Only list namespaces which are labeled as Kargo projects
+	selector := labels.Set{v1alpha1.LabelProjectKey: v1alpha1.LabelTrueValue}.AsSelector()
+	nsList := &corev1.NamespaceList{}
+	if err := s.client.List(ctx, nsList, client.MatchingLabelsSelector{Selector: selector}); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+
+	var projects []*svcv1alpha1.Project
+	for _, ns := range nsList.Items {
+		projects = append(projects, &svcv1alpha1.Project{
+			Name:       ns.Name,
+			CreateTime: timestamppb.New(ns.CreationTimestamp.Time),
+		})
+	}
+
+	return connect.NewResponse(&svcv1alpha1.ListProjectsResponse{
+		Projects: projects,
+	}), nil
+}
diff --git a/internal/api/list_promotion_policies_v1alpha1.go b/internal/api/list_promotion_policies_v1alpha1.go
new file mode 100644
index 00000000..f18d2309
--- /dev/null
+++ b/internal/api/list_promotion_policies_v1alpha1.go
@@ -0,0 +1,38 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+	"github.com/akuity/kargo/pkg/api/v1alpha1"
+)
+
+func (s *server) ListPromotionPolicies(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.ListPromotionPoliciesRequest],
+) (*connect.Response[svcv1alpha1.ListPromotionPoliciesResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var list kubev1alpha1.PromotionPolicyList
+	if err := s.client.List(ctx, &list, client.InNamespace(req.Msg.GetProject())); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	policies := make([]*v1alpha1.PromotionPolicy, len(list.Items))
+	for idx, policy := range list.Items {
+		policies[idx] = typesv1alpha1.ToPromotionPolicyProto(policy)
+	}
+	return connect.NewResponse(&svcv1alpha1.ListPromotionPoliciesResponse{
+		PromotionPolicies: policies,
+	}), nil
+}
diff --git a/internal/api/list_stages_v1alpha1.go b/internal/api/list_stages_v1alpha1.go
new file mode 100644
index 00000000..0340bb5a
--- /dev/null
+++ b/internal/api/list_stages_v1alpha1.go
@@ -0,0 +1,39 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+	"github.com/akuity/kargo/pkg/api/v1alpha1"
+)
+
+func (s *server) ListStages(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.ListStagesRequest],
+) (*connect.Response[svcv1alpha1.ListStagesResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var list kubev1alpha1.StageList
+	if err := s.client.List(ctx, &list, client.InNamespace(req.Msg.GetProject())); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+
+	stages := make([]*v1alpha1.Stage, len(list.Items))
+	for idx := range list.Items {
+		stages[idx] = typesv1alpha1.ToStageProto(list.Items[idx])
+	}
+	return connect.NewResponse(&svcv1alpha1.ListStagesResponse{
+		Stages: stages,
+	}), nil
+}
diff --git a/internal/api/promote_stage_v1alpha1.go b/internal/api/promote_stage_v1alpha1.go
new file mode 100644
index 00000000..b0c8b23f
--- /dev/null
+++ b/internal/api/promote_stage_v1alpha1.go
@@ -0,0 +1,82 @@
+package api
+
+import (
+	"context"
+	"fmt"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/utils/pointer"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) PromoteStage(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.PromoteStageRequest],
+) (*connect.Response[svcv1alpha1.PromoteStageResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if req.Msg.GetState() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("state should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var stage kubev1alpha1.Stage
+	if err := s.client.Get(ctx, client.ObjectKey{
+		Namespace: req.Msg.GetProject(),
+		Name:      req.Msg.GetName(),
+	}, &stage); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound,
+				fmt.Errorf("stage %q not found", req.Msg.GetName()))
+		}
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+
+	stateExists := false
+	for _, state := range stage.Status.AvailableStates {
+		if req.Msg.GetState() == state.ID {
+			stateExists = true
+			break
+		}
+	}
+	if !stateExists {
+		return nil, connect.NewError(connect.CodeNotFound,
+			fmt.Errorf("state %q not found in Stage %q", req.Msg.GetState(), stage.Name))
+	}
+
+	promotion := &kubev1alpha1.Promotion{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: fmt.Sprintf("%s-", req.Msg.GetName()),
+			Namespace:    req.Msg.GetProject(),
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion:         kubev1alpha1.GroupVersion.String(),
+					Kind:               "Stage",
+					Name:               stage.Name,
+					UID:                stage.UID,
+					BlockOwnerDeletion: pointer.Bool(true),
+				},
+			},
+		},
+		Spec: &kubev1alpha1.PromotionSpec{
+			Stage: stage.Name,
+			State: req.Msg.GetState(),
+		},
+	}
+	if err := s.client.Create(ctx, promotion); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.PromoteStageResponse{
+		Promotion: typesv1alpha1.ToPromotionProto(*promotion),
+	}), nil
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index c27158ef..b85ac2dd 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -7,7 +7,6 @@ import (
 	goos "os"
 	"time"
 
-	"connectrpc.com/connect"
 	"connectrpc.com/grpchealth"
 	"github.com/pkg/errors"
 	"golang.org/x/net/http2"
@@ -15,13 +14,10 @@ import (
 
 	"github.com/akuity/kargo/internal/api/config"
 	"github.com/akuity/kargo/internal/api/dex"
-	"github.com/akuity/kargo/internal/api/handler"
 	"github.com/akuity/kargo/internal/api/kubernetes"
 	"github.com/akuity/kargo/internal/api/option"
 	httputil "github.com/akuity/kargo/internal/http"
 	"github.com/akuity/kargo/internal/logging"
-	"github.com/akuity/kargo/internal/version"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 	"github.com/akuity/kargo/pkg/api/service/v1alpha1/svcv1alpha1connect"
 )
 
@@ -107,137 +103,3 @@ func (s *server) newDashboardRequestHandler() http.HandlerFunc {
 		}
 	}
 }
-
-func (s *server) GetVersionInfo(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.GetVersionInfoRequest],
-) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
-	return handler.GetVersionInfoV1Alpha1(version.GetVersion())(ctx, req)
-}
-
-func (s *server) GetPublicConfig(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.GetPublicConfigRequest],
-) (*connect.Response[svcv1alpha1.GetPublicConfigResponse], error) {
-	return handler.GetPublicConfigV1Alpha1(s.cfg)(ctx, req)
-}
-
-func (s *server) AdminLogin(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.AdminLoginRequest],
-) (*connect.Response[svcv1alpha1.AdminLoginResponse], error) {
-	return handler.AdminLoginV1Alpha1(s.cfg.AdminConfig)(ctx, req)
-}
-
-func (s *server) CreateStage(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.CreateStageRequest],
-) (*connect.Response[svcv1alpha1.CreateStageResponse], error) {
-	return handler.CreateStageV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) ListStages(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.ListStagesRequest],
-) (*connect.Response[svcv1alpha1.ListStagesResponse], error) {
-	return handler.ListStagesV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) GetStage(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.GetStageRequest],
-) (*connect.Response[svcv1alpha1.GetStageResponse], error) {
-	return handler.GetStageV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) WatchStages(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.WatchStagesRequest],
-	stream *connect.ServerStream[svcv1alpha1.WatchStagesResponse],
-) error {
-	return handler.WatchStageV1Alpha1(s.client)(ctx, req, stream)
-}
-
-func (s *server) UpdateStage(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.UpdateStageRequest],
-) (*connect.Response[svcv1alpha1.UpdateStageResponse], error) {
-	return handler.UpdateStageV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) DeleteStage(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.DeleteStageRequest],
-) (*connect.Response[svcv1alpha1.DeleteStageResponse], error) {
-	return handler.DeleteStageV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) PromoteStage(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.PromoteStageRequest],
-) (*connect.Response[svcv1alpha1.PromoteStageResponse], error) {
-	return handler.PromoteStageV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) SetAutoPromotionForStage(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.SetAutoPromotionForStageRequest],
-) (*connect.Response[svcv1alpha1.SetAutoPromotionForStageResponse], error) {
-	return handler.SetAutoPromotionForStageV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) CreatePromotionPolicy(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.CreatePromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.CreatePromotionPolicyResponse], error) {
-	return handler.CreatePromotionPolicyV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) ListPromotionPolicies(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.ListPromotionPoliciesRequest],
-) (*connect.Response[svcv1alpha1.ListPromotionPoliciesResponse], error) {
-	return handler.ListPromotionPoliciesV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) GetPromotionPolicy(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.GetPromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.GetPromotionPolicyResponse], error) {
-	return handler.GetPromotionPolicyV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) UpdatePromotionPolicy(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.UpdatePromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.UpdatePromotionPolicyResponse], error) {
-	return handler.UpdatePromotionPolicyV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) DeletePromotionPolicy(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.DeletePromotionPolicyRequest],
-) (*connect.Response[svcv1alpha1.DeletePromotionPolicyResponse], error) {
-	return handler.DeletePromotionPolicyV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) CreateProject(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.CreateProjectRequest],
-) (*connect.Response[svcv1alpha1.CreateProjectResponse], error) {
-	return handler.CreateProjectV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) ListProjects(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.ListProjectsRequest],
-) (*connect.Response[svcv1alpha1.ListProjectsResponse], error) {
-	return handler.ListProjectsV1Alpha1(s.client)(ctx, req)
-}
-
-func (s *server) DeleteProject(
-	ctx context.Context,
-	req *connect.Request[svcv1alpha1.DeleteProjectRequest],
-) (*connect.Response[svcv1alpha1.DeleteProjectResponse], error) {
-	return handler.DeleteProjectV1Alpha1(s.client)(ctx, req)
-}
diff --git a/internal/api/set_auto_promotion_for_stage_v1alpha1.go b/internal/api/set_auto_promotion_for_stage_v1alpha1.go
new file mode 100644
index 00000000..b233e99a
--- /dev/null
+++ b/internal/api/set_auto_promotion_for_stage_v1alpha1.go
@@ -0,0 +1,63 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	"github.com/akuity/kargo/internal/kubeclient"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) SetAutoPromotionForStage(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.SetAutoPromotionForStageRequest],
+) (*connect.Response[svcv1alpha1.SetAutoPromotionForStageResponse], error) {
+	if req.Msg.GetProject() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if req.Msg.GetStage() == "" {
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("stage should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return nil, err
+	}
+
+	var policyList kubev1alpha1.PromotionPolicyList
+	if err := s.client.List(ctx, &policyList, client.InNamespace(req.Msg.GetProject()), client.MatchingFields{
+		kubeclient.PromotionPoliciesByStageIndexField: req.Msg.GetStage(),
+	}); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "list promotion policies"))
+	}
+
+	// Since only one PromotionPolicy is allowed per stage,
+	// create if not exists and update if exists.
+	var policy kubev1alpha1.PromotionPolicy
+	if len(policyList.Items) > 0 {
+		policy = policyList.Items[0]
+		policy.EnableAutoPromotion = req.Msg.GetEnable()
+		if err := s.client.Update(ctx, &policy); err != nil {
+			return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "update promotion policy"))
+		}
+	} else {
+		policy = kubev1alpha1.PromotionPolicy{
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: req.Msg.GetProject(),
+				Name:      req.Msg.GetStage(),
+			},
+			Stage:               req.Msg.GetStage(),
+			EnableAutoPromotion: req.Msg.GetEnable(),
+		}
+		if err := s.client.Create(ctx, &policy); err != nil {
+			return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create promotion policy"))
+		}
+	}
+	return connect.NewResponse(&svcv1alpha1.SetAutoPromotionForStageResponse{
+		PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
+	}), nil
+}
diff --git a/internal/api/handler/testdata/namespace.yaml b/internal/api/testdata/namespace.yaml
similarity index 100%
rename from internal/api/handler/testdata/namespace.yaml
rename to internal/api/testdata/namespace.yaml
diff --git a/internal/api/handler/testdata/stage.yaml b/internal/api/testdata/stage.yaml
similarity index 100%
rename from internal/api/handler/testdata/stage.yaml
rename to internal/api/testdata/stage.yaml
diff --git a/internal/api/update_promotion_policy_v1alpha1.go b/internal/api/update_promotion_policy_v1alpha1.go
new file mode 100644
index 00000000..336d6240
--- /dev/null
+++ b/internal/api/update_promotion_policy_v1alpha1.go
@@ -0,0 +1,63 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/yaml"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) UpdatePromotionPolicy(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.UpdatePromotionPolicyRequest],
+) (*connect.Response[svcv1alpha1.UpdatePromotionPolicyResponse], error) {
+	var policy kubev1alpha1.PromotionPolicy
+	switch {
+	case req.Msg.GetYaml() != "":
+		if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &policy); err != nil {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
+		}
+	case req.Msg.GetTyped() != nil:
+		if req.Msg.GetTyped().GetProject() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+		}
+		if req.Msg.GetTyped().GetName() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+		}
+		policy = kubev1alpha1.PromotionPolicy{
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: req.Msg.GetTyped().GetProject(),
+				Name:      req.Msg.GetTyped().GetName(),
+			},
+			Stage:               req.Msg.GetTyped().GetStage(),
+			EnableAutoPromotion: req.Msg.GetTyped().GetEnableAutoPromotion(),
+		}
+	default:
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("promotion_policy should not be empty"))
+	}
+
+	if err := s.validateProject(ctx, policy.GetNamespace()); err != nil {
+		return nil, err
+	}
+	var existingPolicy kubev1alpha1.PromotionPolicy
+	if err := s.client.Get(ctx, client.ObjectKeyFromObject(&policy), &existingPolicy); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound, err)
+		}
+	}
+	policy.SetResourceVersion(existingPolicy.GetResourceVersion())
+	if err := s.client.Update(ctx, &policy); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.UpdatePromotionPolicyResponse{
+		PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
+	}), nil
+}
diff --git a/internal/api/update_stage_v1alpha1.go b/internal/api/update_stage_v1alpha1.go
new file mode 100644
index 00000000..350e5881
--- /dev/null
+++ b/internal/api/update_stage_v1alpha1.go
@@ -0,0 +1,62 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/yaml"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) UpdateStage(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.UpdateStageRequest],
+) (*connect.Response[svcv1alpha1.UpdateStageResponse], error) {
+	var stage kubev1alpha1.Stage
+	switch {
+	case req.Msg.GetYaml() != "":
+		if err := yaml.Unmarshal([]byte(req.Msg.GetYaml()), &stage); err != nil {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrap(err, "invalid yaml"))
+		}
+	case req.Msg.GetTyped() != nil:
+		if req.Msg.GetTyped().GetProject() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+		}
+		if req.Msg.GetTyped().GetName() == "" {
+			return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name should not be empty"))
+		}
+		stage = kubev1alpha1.Stage{
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: req.Msg.GetTyped().GetProject(),
+				Name:      req.Msg.GetTyped().GetName(),
+			},
+			Spec: typesv1alpha1.FromStageSpecProto(req.Msg.GetTyped().GetSpec()),
+		}
+	default:
+		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("stage should not be empty"))
+	}
+
+	if err := s.validateProject(ctx, stage.GetNamespace()); err != nil {
+		return nil, err
+	}
+	var existingStage kubev1alpha1.Stage
+	if err := s.client.Get(ctx, client.ObjectKeyFromObject(&stage), &existingStage); err != nil {
+		if kubeerr.IsNotFound(err) {
+			return nil, connect.NewError(connect.CodeNotFound, err)
+		}
+	}
+	stage.SetResourceVersion(existingStage.GetResourceVersion())
+	if err := s.client.Update(ctx, &stage); err != nil {
+		return nil, connect.NewError(connect.CodeInternal, err)
+	}
+	return connect.NewResponse(&svcv1alpha1.UpdateStageResponse{
+		Stage: typesv1alpha1.ToStageProto(stage),
+	}), nil
+}
diff --git a/internal/api/validators.go b/internal/api/validators.go
new file mode 100644
index 00000000..10a434df
--- /dev/null
+++ b/internal/api/validators.go
@@ -0,0 +1,25 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/util/validation/field"
+
+	"github.com/akuity/kargo/internal/api/validation"
+)
+
+func (s *server) validateProject(ctx context.Context, project string) error {
+	if err := validation.ValidateProject(ctx, s.client, project); err != nil {
+		if errors.Is(err, validation.ErrProjectNotFound) {
+			return connect.NewError(connect.CodeNotFound, err)
+		}
+		var fieldErr *field.Error
+		if ok := errors.As(err, &fieldErr); ok {
+			return connect.NewError(connect.CodeInvalidArgument, err)
+		}
+		return connect.NewError(connect.CodeInternal, err)
+	}
+	return nil
+}
diff --git a/internal/api/watch_stages_v1alpha1.go b/internal/api/watch_stages_v1alpha1.go
new file mode 100644
index 00000000..9e786d37
--- /dev/null
+++ b/internal/api/watch_stages_v1alpha1.go
@@ -0,0 +1,78 @@
+package api
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/fields"
+	"k8s.io/apimachinery/pkg/runtime"
+	libClient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	kargov1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func (s *server) WatchStages(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.WatchStagesRequest],
+	stream *connect.ServerStream[svcv1alpha1.WatchStagesResponse],
+) error {
+	if req.Msg.GetProject() == "" {
+		return connect.NewError(connect.CodeInvalidArgument, errors.New("project should not be empty"))
+	}
+	if err := s.validateProject(ctx, req.Msg.GetProject()); err != nil {
+		return err
+	}
+
+	if req.Msg.GetName() != "" {
+		if err := s.client.Get(ctx, libClient.ObjectKey{
+			Namespace: req.Msg.GetProject(),
+			Name:      req.Msg.GetName(),
+		}, &kargov1alpha1.Stage{}); err != nil {
+			if kubeerr.IsNotFound(err) {
+				return connect.NewError(connect.CodeNotFound, err)
+			}
+			return connect.NewError(connect.CodeInternal, err)
+		}
+	}
+
+	opts := metav1.ListOptions{}
+	if req.Msg.GetName() != "" {
+		opts.FieldSelector = fields.OneTermEqualSelector(metav1.ObjectNameField, req.Msg.GetName()).String()
+	}
+	w, err :=
+		s.client.Watch(ctx, &kargov1alpha1.Stage{}, req.Msg.GetProject(), opts)
+	if err != nil {
+		return errors.Wrap(err, "watch stage")
+	}
+	defer w.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case e, ok := <-w.ResultChan():
+			if !ok {
+				return nil
+			}
+			u, ok := e.Object.(*unstructured.Unstructured)
+			if !ok {
+				return errors.Errorf("unexpected object type %T", e.Object)
+			}
+			var stage *kargov1alpha1.Stage
+			if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &stage); err != nil {
+				return errors.Wrap(err, "from unstructured")
+			}
+			if err := stream.Send(&svcv1alpha1.WatchStagesResponse{
+				Stage: typesv1alpha1.ToStageProto(*stage),
+				Type:  string(e.Type),
+			}); err != nil {
+				return errors.Wrap(err, "send response")
+			}
+		}
+	}
+}


Test patch:
diff --git a/internal/api/handler/get_stage_v1alpha1_test.go b/internal/api/get_stage_v1alpha1_test.go
similarity index 61%
rename from internal/api/handler/get_stage_v1alpha1_test.go
rename to internal/api/get_stage_v1alpha1_test.go
index 4147c5b1..89f76c37 100644
--- a/internal/api/handler/get_stage_v1alpha1_test.go
+++ b/internal/api/get_stage_v1alpha1_test.go
@@ -1,4 +1,4 @@
-package handler
+package api
 
 import (
 	"context"
@@ -7,13 +7,18 @@ import (
 	"connectrpc.com/connect"
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/rest"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 
 	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/kubernetes"
+	"github.com/akuity/kargo/internal/api/user"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func TestGetStageV1Alpha1(t *testing.T) {
+func TestGetStage(t *testing.T) {
 	testSets := map[string]struct {
 		req          *svcv1alpha1.GetStageRequest
 		errExpected  bool
@@ -63,16 +68,39 @@ func TestGetStageV1Alpha1(t *testing.T) {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
 
-			kc := fake.NewClientBuilder().
-				WithScheme(mustNewScheme()).
-				WithObjects(
-					mustNewObject[corev1.Namespace]("testdata/namespace.yaml"),
-					mustNewObject[kubev1alpha1.Stage]("testdata/stage.yaml"),
-				).
-				Build()
+			// Simulate an admin user to prevent any authz issues with the authorizing
+			// client.
+			ctx := user.ContextWithInfo(
+				context.Background(),
+				user.Info{
+					IsAdmin: true,
+				},
+			)
 
-			res, err :=
-				GetStageV1Alpha1(kc)(context.Background(), connect.NewRequest(ts.req))
+			client, err := kubernetes.NewClient(
+				ctx,
+				&rest.Config{},
+				kubernetes.ClientOptions{
+					NewInternalClient: func(
+						_ context.Context,
+						_ *rest.Config,
+						scheme *runtime.Scheme,
+					) (client.Client, error) {
+						return fake.NewClientBuilder().
+							WithScheme(scheme).
+							WithObjects(
+								mustNewObject[corev1.Namespace]("testdata/namespace.yaml"),
+								mustNewObject[kubev1alpha1.Stage]("testdata/stage.yaml"),
+							).
+							Build(), nil
+					},
+				},
+			)
+			require.NoError(t, err)
+
+			res, err := (&server{
+				client: client,
+			}).GetStage(ctx, connect.NewRequest(ts.req))
 			if ts.errExpected {
 				require.Error(t, err)
 				require.Equal(t, ts.expectedCode, connect.CodeOf(err))
diff --git a/internal/api/handler/list_stages_v1alpha1_test.go b/internal/api/handler/list_stages_v1alpha1_test.go
deleted file mode 100644
index 95a58c33..00000000
--- a/internal/api/handler/list_stages_v1alpha1_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package handler
-
-import (
-	"context"
-	"testing"
-
-	"connectrpc.com/connect"
-	"github.com/stretchr/testify/require"
-	corev1 "k8s.io/api/core/v1"
-	"sigs.k8s.io/controller-runtime/pkg/client/fake"
-
-	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
-	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
-)
-
-func TestListStagesV1Alpha1(t *testing.T) {
-	testSets := map[string]struct {
-		req          *svcv1alpha1.ListStagesRequest
-		errExpected  bool
-		expectedCode connect.Code
-	}{
-		"empty project": {
-			req: &svcv1alpha1.ListStagesRequest{
-				Project: "",
-			},
-			errExpected:  true,
-			expectedCode: connect.CodeInvalidArgument,
-		},
-		"existing project": {
-			req: &svcv1alpha1.ListStagesRequest{
-				Project: "kargo-demo",
-			},
-		},
-		"non-existing project": {
-			req: &svcv1alpha1.ListStagesRequest{
-				Project: "non-existing-project",
-			},
-			errExpected:  true,
-			expectedCode: connect.CodeNotFound,
-		},
-	}
-	for name, ts := range testSets {
-		ts := ts
-		t.Run(name, func(t *testing.T) {
-			t.Parallel()
-
-			kc := fake.NewClientBuilder().
-				WithScheme(mustNewScheme()).
-				WithObjects(
-					mustNewObject[corev1.Namespace]("testdata/namespace.yaml"),
-				).
-				WithLists(&kubev1alpha1.StageList{
-					Items: []kubev1alpha1.Stage{
-						*mustNewObject[kubev1alpha1.Stage]("testdata/stage.yaml"),
-					},
-				}).
-				Build()
-
-			res, err :=
-				ListStagesV1Alpha1(kc)(context.Background(), connect.NewRequest(ts.req))
-			if ts.errExpected {
-				require.Error(t, err)
-				require.Equal(t, ts.expectedCode, connect.CodeOf(err))
-				return
-			}
-			require.Len(t, res.Msg.GetStages(), 1)
-		})
-	}
-}
diff --git a/internal/api/handler/handler_test.go b/internal/api/handler_test.go
similarity index 98%
rename from internal/api/handler/handler_test.go
rename to internal/api/handler_test.go
index c68e11c8..3b670eb4 100644
--- a/internal/api/handler/handler_test.go
+++ b/internal/api/handler_test.go
@@ -1,4 +1,4 @@
-package handler
+package api
 
 import (
 	"embed"
diff --git a/internal/api/list_stages_v1alpha1_test.go b/internal/api/list_stages_v1alpha1_test.go
new file mode 100644
index 00000000..93794dda
--- /dev/null
+++ b/internal/api/list_stages_v1alpha1_test.go
@@ -0,0 +1,97 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/rest"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/kubernetes"
+	"github.com/akuity/kargo/internal/api/user"
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func TestListStages(t *testing.T) {
+	testSets := map[string]struct {
+		req          *svcv1alpha1.ListStagesRequest
+		errExpected  bool
+		expectedCode connect.Code
+	}{
+		"empty project": {
+			req: &svcv1alpha1.ListStagesRequest{
+				Project: "",
+			},
+			errExpected:  true,
+			expectedCode: connect.CodeInvalidArgument,
+		},
+		"existing project": {
+			req: &svcv1alpha1.ListStagesRequest{
+				Project: "kargo-demo",
+			},
+		},
+		"non-existing project": {
+			req: &svcv1alpha1.ListStagesRequest{
+				Project: "non-existing-project",
+			},
+			errExpected:  true,
+			expectedCode: connect.CodeNotFound,
+		},
+	}
+	for name, ts := range testSets {
+		ts := ts
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			// Simulate an admin user to prevent any authz issues with the authorizing
+			// client.
+			ctx := user.ContextWithInfo(
+				context.Background(),
+				user.Info{
+					IsAdmin: true,
+				},
+			)
+
+			client, err := kubernetes.NewClient(
+				ctx,
+				&rest.Config{},
+				kubernetes.ClientOptions{
+					NewInternalClient: func(
+						context.Context,
+						*rest.Config,
+						*runtime.Scheme,
+					) (client.Client, error) {
+						return fake.NewClientBuilder().
+							WithScheme(mustNewScheme()).
+							WithObjects(
+								mustNewObject[corev1.Namespace]("testdata/namespace.yaml"),
+							).
+							WithLists(&kubev1alpha1.StageList{
+								Items: []kubev1alpha1.Stage{
+									*mustNewObject[kubev1alpha1.Stage]("testdata/stage.yaml"),
+								},
+							}).
+							Build(), nil
+					},
+				},
+			)
+			require.NoError(t, err)
+
+			res, err := (&server{
+				client: client,
+			}).ListStages(ctx, connect.NewRequest(ts.req))
+			if ts.errExpected {
+				require.Error(t, err)
+				require.Equal(t, ts.expectedCode, connect.CodeOf(err))
+				return
+			}
+			require.Len(t, res.Msg.GetStages(), 1)
+		})
+	}
+}
diff --git a/internal/api/handler/promote_stage_v1alpha1_test.go b/internal/api/promote_stage_v1alpha1_test.go
similarity index 61%
rename from internal/api/handler/promote_stage_v1alpha1_test.go
rename to internal/api/promote_stage_v1alpha1_test.go
index 7672550d..006e62c8 100644
--- a/internal/api/handler/promote_stage_v1alpha1_test.go
+++ b/internal/api/promote_stage_v1alpha1_test.go
@@ -1,4 +1,4 @@
-package handler
+package api
 
 import (
 	"context"
@@ -8,14 +8,18 @@ import (
 	"connectrpc.com/connect"
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
-	"sigs.k8s.io/controller-runtime/pkg/client"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/rest"
+	libClient "sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 
 	kubev1alpha1 "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/kubernetes"
+	"github.com/akuity/kargo/internal/api/user"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func TestPromoteStageV1Alpha1(t *testing.T) {
+func TestPromoteStage(t *testing.T) {
 	testSets := map[string]struct {
 		req          *svcv1alpha1.PromoteStageRequest
 		errExpected  bool
@@ -60,20 +64,43 @@ func TestPromoteStageV1Alpha1(t *testing.T) {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
 
-			kc := fake.NewClientBuilder().
-				WithScheme(mustNewScheme()).
-				WithObjects(
-					mustNewObject[corev1.Namespace]("testdata/namespace.yaml"),
-				).
-				WithLists(&kubev1alpha1.StageList{
-					Items: []kubev1alpha1.Stage{
-						*mustNewObject[kubev1alpha1.Stage]("testdata/stage.yaml"),
+			// Simulate an admin user to prevent any authz issues with the authorizing
+			// client.
+			ctx := user.ContextWithInfo(
+				context.Background(),
+				user.Info{
+					IsAdmin: true,
+				},
+			)
+
+			client, err := kubernetes.NewClient(
+				ctx,
+				&rest.Config{},
+				kubernetes.ClientOptions{
+					NewInternalClient: func(
+						context.Context,
+						*rest.Config,
+						*runtime.Scheme,
+					) (libClient.Client, error) {
+						return fake.NewClientBuilder().
+							WithScheme(mustNewScheme()).
+							WithObjects(
+								mustNewObject[corev1.Namespace]("testdata/namespace.yaml"),
+							).
+							WithLists(&kubev1alpha1.StageList{
+								Items: []kubev1alpha1.Stage{
+									*mustNewObject[kubev1alpha1.Stage]("testdata/stage.yaml"),
+								},
+							}).
+							Build(), nil
 					},
-				}).
-				Build()
+				},
+			)
+			require.NoError(t, err)
 
-			ctx := context.Background()
-			res, err := PromoteStageV1Alpha1(kc)(ctx, connect.NewRequest(ts.req))
+			res, err := (&server{
+				client: client,
+			}).PromoteStage(ctx, connect.NewRequest(ts.req))
 			if ts.errExpected {
 				require.Error(t, err)
 				require.Equal(t, ts.expectedCode, connect.CodeOf(err))
@@ -85,7 +112,7 @@ func TestPromoteStageV1Alpha1(t *testing.T) {
 			require.Equal(t, ts.req.GetState(), res.Msg.GetPromotion().GetSpec().GetState())
 
 			var actual kubev1alpha1.Promotion
-			require.NoError(t, kc.Get(ctx, client.ObjectKey{
+			require.NoError(t, client.Get(ctx, libClient.ObjectKey{
 				Namespace: ts.req.GetProject(),
 				Name:      res.Msg.GetPromotion().GetMetadata().GetName(),
 			}, &actual))