diff --git a/internal/dashboardsvc/context.go b/internal/dashboardsvc/context.go deleted file mode 100644 index c9d7aaa..0000000 --- a/internal/dashboardsvc/context.go +++ /dev/null @@ -1,41 +0,0 @@ -package dashboardsvc - -import ( - "context" - - "github.com/google/uuid" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type ctxKey int - -const ( - ctxKeyTeamID ctxKey = iota + 1 - ctxKeyUserID -) - -func contextWithAuth(ctx context.Context, teamID, userID uuid.UUID) context.Context { - ctx = context.WithValue(ctx, ctxKeyTeamID, teamID) - ctx = context.WithValue(ctx, ctxKeyUserID, userID) - return ctx -} - -func authTeamID(ctx context.Context) (uuid.UUID, error) { - v := ctx.Value(ctxKeyTeamID) - t, ok := v.(uuid.UUID) - if !ok || v == nil { - return uuid.Nil, status.Error(codes.Unauthenticated, "not authenticated") - } - return t, nil -} - -func authUserID(ctx context.Context) (uuid.UUID, error) { - v := ctx.Value(ctxKeyUserID) - u, ok := v.(uuid.UUID) - if !ok || v == nil { - return uuid.Nil, status.Error(codes.Unauthenticated, "not authenticated") - } - return u, nil -} - diff --git a/internal/dashboardsvc/interceptor.go b/internal/dashboardsvc/interceptor.go deleted file mode 100644 index f3a3db7..0000000 --- a/internal/dashboardsvc/interceptor.go +++ /dev/null @@ -1,74 +0,0 @@ -package dashboardsvc - -import ( - "context" - "errors" - "strings" - - "github.com/golang-jwt/jwt/v4" - "github.com/google/uuid" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -// sessionClaims mirrors middleware.sessionClaims (JWT issued after OAuth / CLI auth). -type sessionClaims struct { - UserID string `json:"uid"` - TeamID string `json:"tid"` - Email string `json:"email"` - jwt.RegisteredClaims -} - -func (c sessionClaims) Valid() error { - c.RegisteredClaims.IssuedAt = nil - return c.RegisteredClaims.Valid() -} - -// AuthInterceptor validates the gRPC "authorization" metadata (Bearer JWT) the same -// way HTTP RequireAuth does, then attaches team_id and user_id to the context. -func AuthInterceptor(jwtSecret string) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, status.Error(codes.Unauthenticated, "missing metadata") - } - vals := md.Get("authorization") - if len(vals) != 1 { - return nil, status.Error(codes.Unauthenticated, "missing authorization") - } - header := strings.TrimSpace(vals[0]) - const bearerPrefix = "Bearer " - if len(header) < len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) { - return nil, status.Error(codes.Unauthenticated, "invalid authorization scheme") - } - tokenStr := strings.TrimSpace(header[len(bearerPrefix):]) - - claims := &sessionClaims{} - parsed, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, errors.New("unexpected signing method") - } - return []byte(jwtSecret), nil - }) - if err != nil || !parsed.Valid { - return nil, status.Error(codes.Unauthenticated, "invalid token") - } - if claims.UserID == "" || claims.TeamID == "" { - return nil, status.Error(codes.Unauthenticated, "invalid token claims") - } - - teamID, err := uuid.Parse(claims.TeamID) - if err != nil { - return nil, status.Error(codes.Unauthenticated, "invalid team in token") - } - userID, err := uuid.Parse(claims.UserID) - if err != nil { - return nil, status.Error(codes.Unauthenticated, "invalid user in token") - } - - ctx = contextWithAuth(ctx, teamID, userID) - return handler(ctx, req) - } -} diff --git a/internal/dashboardsvc/interceptor_test.go b/internal/dashboardsvc/interceptor_test.go deleted file mode 100644 index 92ce531..0000000 --- a/internal/dashboardsvc/interceptor_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package dashboardsvc - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" - - "instant.dev/internal/testhelpers" -) - -func TestAuthInterceptor_MissingMetadata(t *testing.T) { - t.Parallel() - iv := AuthInterceptor(testhelpers.TestJWTSecret) - _, err := iv(context.Background(), nil, &grpc.UnaryServerInfo{}, func(context.Context, interface{}) (interface{}, error) { - t.Fatal("handler must not run") - return nil, nil - }) - require.Error(t, err) - require.Equal(t, codes.Unauthenticated, status.Code(err)) -} - -func TestAuthInterceptor_MissingAuthorization(t *testing.T) { - t.Parallel() - iv := AuthInterceptor(testhelpers.TestJWTSecret) - ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-other", "x")) - _, err := iv(ctx, nil, &grpc.UnaryServerInfo{}, func(context.Context, interface{}) (interface{}, error) { - t.Fatal("handler must not run") - return nil, nil - }) - require.Error(t, err) - require.Equal(t, codes.Unauthenticated, status.Code(err)) -} - -func TestAuthInterceptor_InvalidScheme(t *testing.T) { - t.Parallel() - iv := AuthInterceptor(testhelpers.TestJWTSecret) - ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Basic abc")) - _, err := iv(ctx, nil, &grpc.UnaryServerInfo{}, func(context.Context, interface{}) (interface{}, error) { - t.Fatal("handler must not run") - return nil, nil - }) - require.Error(t, err) - require.Equal(t, codes.Unauthenticated, status.Code(err)) -} - -func TestAuthInterceptor_InvalidJWT(t *testing.T) { - t.Parallel() - iv := AuthInterceptor(testhelpers.TestJWTSecret) - ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer not-a-jwt")) - _, err := iv(ctx, nil, &grpc.UnaryServerInfo{}, func(context.Context, interface{}) (interface{}, error) { - t.Fatal("handler must not run") - return nil, nil - }) - require.Error(t, err) - require.Equal(t, codes.Unauthenticated, status.Code(err)) -} - -func TestAuthInterceptor_ValidJWT_SetsActor(t *testing.T) { - t.Parallel() - teamID := uuid.New() - userID := uuid.New() - tok := testhelpers.MustSignSessionJWT(t, userID.String(), teamID.String(), "a@b.com") - iv := AuthInterceptor(testhelpers.TestJWTSecret) - ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+tok)) - - var sawTeam, sawUser uuid.UUID - _, err := iv(ctx, nil, &grpc.UnaryServerInfo{}, func(ctx context.Context, _ interface{}) (interface{}, error) { - var err2 error - sawTeam, err2 = authTeamID(ctx) - if err2 != nil { - return nil, err2 - } - sawUser, err2 = authUserID(ctx) - return nil, err2 - }) - require.NoError(t, err) - require.Equal(t, teamID, sawTeam) - require.Equal(t, userID, sawUser) -} - -func TestAuthInterceptor_BearerCaseInsensitive(t *testing.T) { - t.Parallel() - teamID := uuid.New() - userID := uuid.New() - tok := testhelpers.MustSignSessionJWT(t, userID.String(), teamID.String(), "a@b.com") - iv := AuthInterceptor(testhelpers.TestJWTSecret) - ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "bearer "+tok)) - - _, err := iv(ctx, nil, &grpc.UnaryServerInfo{}, func(ctx context.Context, _ interface{}) (interface{}, error) { - tid, err2 := authTeamID(ctx) - if err2 != nil { - return nil, err2 - } - uid, err2 := authUserID(ctx) - if err2 != nil { - return nil, err2 - } - require.Equal(t, teamID, tid) - require.Equal(t, userID, uid) - return nil, nil - }) - require.NoError(t, err) -} diff --git a/internal/dashboardsvc/rotate.go b/internal/dashboardsvc/rotate.go deleted file mode 100644 index 5d41484..0000000 --- a/internal/dashboardsvc/rotate.go +++ /dev/null @@ -1,110 +0,0 @@ -package dashboardsvc - -import ( - "context" - "database/sql" - "fmt" - "log/slog" - "time" - - _ "github.com/lib/pq" - "github.com/redis/go-redis/v9" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - mongooptions "go.mongodb.org/mongo-driver/mongo/options" - - "instant.dev/internal/config" - "instant.dev/internal/models" - commonv1 "instant.dev/proto/common/v1" -) - -// rotatePostgresPassword runs ALTER ROLE on postgres-customers (copied from handlers.ResourceHandler). -func rotatePostgresPassword(ctx context.Context, dsn, username, newPassword string) error { - db, err := sql.Open("postgres", dsn) - if err != nil { - return fmt.Errorf("rotatePostgresPassword: open: %w", err) - } - defer db.Close() - - for _, ch := range username { - if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-') { - return fmt.Errorf("rotatePostgresPassword: unsafe username %q", username) - } - } - - _, err = db.ExecContext(ctx, fmt.Sprintf(`ALTER ROLE "%s" WITH PASSWORD '%s'`, username, newPassword)) - if err != nil { - return fmt.Errorf("rotatePostgresPassword: ALTER ROLE: %w", err) - } - return nil -} - -func rotateRedisPassword(ctx context.Context, originalURL, username, newPassword string) error { - opts, err := redis.ParseURL(originalURL) - if err != nil { - return fmt.Errorf("rotateRedisPassword: parse url: %w", err) - } - client := redis.NewClient(opts) - defer client.Close() - - if err := client.Do(ctx, "ACL", "SETUSER", username, "resetpass", ">"+newPassword).Err(); err != nil { - return fmt.Errorf("rotateRedisPassword: ACL SETUSER: %w", err) - } - return nil -} - -func rotateMongoPassword(ctx context.Context, adminURI, username, newPassword string) error { - client, err := mongo.Connect(ctx, mongooptions.Client().ApplyURI(adminURI). - SetServerSelectionTimeout(3*time.Second)) - if err != nil { - return fmt.Errorf("rotateMongoPassword: connect: %w", err) - } - defer func() { - if discErr := client.Disconnect(ctx); discErr != nil { - slog.Warn("rotateMongoPassword: disconnect", "error", discErr) - } - }() - - result := client.Database("admin").RunCommand(ctx, bson.D{ - {Key: "updateUser", Value: username}, - {Key: "pwd", Value: newPassword}, - }) - if result.Err() != nil { - return fmt.Errorf("rotateMongoPassword: updateUser: %w", result.Err()) - } - return nil -} - -func resourceTypeToProto(resourceType string) commonv1.ResourceType { - switch resourceType { - case "postgres": - return commonv1.ResourceType_RESOURCE_TYPE_POSTGRES - case "redis": - return commonv1.ResourceType_RESOURCE_TYPE_REDIS - case "mongodb": - return commonv1.ResourceType_RESOURCE_TYPE_MONGODB - default: - return commonv1.ResourceType_RESOURCE_TYPE_UNSPECIFIED - } -} - -func applyRotatedPassword(ctx context.Context, cfg *config.Config, r *models.Resource, parsedUser, newPassword, plainURL string) { - if r.ResourceType == "postgres" && cfg.CustomerDatabaseURL != "" { - if rotErr := rotatePostgresPassword(ctx, cfg.CustomerDatabaseURL, parsedUser, newPassword); rotErr != nil { - slog.Warn("dashboardsvc.rotate.postgres_alter_role_failed", - "resource_id", r.ID, "error", rotErr) - } - } - if r.ResourceType == "redis" { - if rotErr := rotateRedisPassword(ctx, plainURL, parsedUser, newPassword); rotErr != nil { - slog.Warn("dashboardsvc.rotate.redis_acl_setuser_failed", - "resource_id", r.ID, "error", rotErr) - } - } - if r.ResourceType == "mongodb" && cfg.MongoAdminURI != "" { - if rotErr := rotateMongoPassword(ctx, cfg.MongoAdminURI, parsedUser, newPassword); rotErr != nil { - slog.Warn("dashboardsvc.rotate.mongo_update_user_failed", - "resource_id", r.ID, "error", rotErr) - } - } -} diff --git a/internal/dashboardsvc/server.go b/internal/dashboardsvc/server.go deleted file mode 100644 index c29992e..0000000 --- a/internal/dashboardsvc/server.go +++ /dev/null @@ -1,799 +0,0 @@ -package dashboardsvc - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/hex" - "errors" - "fmt" - "log/slog" - "net/url" - "strings" - "time" - - "github.com/google/uuid" - razorpay "github.com/razorpay/razorpay-go" - "github.com/redis/go-redis/v9" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "instant.dev/internal/config" - "instant.dev/internal/crypto" - "instant.dev/internal/email" - "instant.dev/internal/models" - "instant.dev/internal/plans" - compute "instant.dev/internal/providers/compute" - storageprovider "instant.dev/internal/providers/storage" - "instant.dev/internal/provisioner" - "instant.dev/internal/quota" - "instant.dev/internal/razorpaybilling" - commonv1 "instant.dev/proto/common/v1" - dashboardv1 "instant.dev/proto/dashboard/v1" -) - -// Server implements dashboardv1.DashboardServiceServer for dashboard-api. -type Server struct { - dashboardv1.UnimplementedDashboardServiceServer - db *sql.DB - rdb *redis.Client - cfg *config.Config - plans *plans.Registry - provisioner *provisioner.Client - storageProvider *storageprovider.Provider - mail *email.Client - stackProv compute.StackProvider -} - -// NewServer constructs a Dashboard gRPC service implementation. -func NewServer(db *sql.DB, rdb *redis.Client, cfg *config.Config, reg *plans.Registry, prov *provisioner.Client, storageProv *storageprovider.Provider, mail *email.Client, stackProv compute.StackProvider) *Server { - return &Server{ - db: db, - rdb: rdb, - cfg: cfg, - plans: reg, - provisioner: prov, - storageProvider: storageProv, - mail: mail, - stackProv: stackProv, - } -} - -func (s *Server) requireMatchingTeam(ctx context.Context, requestedTeam string) (uuid.UUID, error) { - authTeam, err := authTeamID(ctx) - if err != nil { - return uuid.Nil, err - } - if strings.TrimSpace(requestedTeam) == "" { - return uuid.Nil, status.Error(codes.InvalidArgument, "team_id required") - } - reqTeam, err := uuid.Parse(requestedTeam) - if err != nil { - return uuid.Nil, status.Error(codes.InvalidArgument, "invalid team_id") - } - if authTeam != reqTeam { - return uuid.Nil, status.Error(codes.PermissionDenied, "team_id does not match authenticated session") - } - return authTeam, nil -} - -func (s *Server) requireMatchingUser(ctx context.Context, requestedUser string) error { - if strings.TrimSpace(requestedUser) == "" { - return nil - } - authUser, err := authUserID(ctx) - if err != nil { - return err - } - reqUser, err := uuid.Parse(requestedUser) - if err != nil { - return status.Error(codes.InvalidArgument, "invalid user_id") - } - if authUser != reqUser { - return status.Error(codes.PermissionDenied, "user_id does not match authenticated session") - } - return nil -} - -func slugify(name, teamID string) string { - if name == "" { - if len(teamID) >= 8 { - return teamID[:8] - } - return teamID - } - slug := strings.ToLower(name) - slug = strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - return r - } - return '-' - }, slug) - for strings.Contains(slug, "--") { - slug = strings.ReplaceAll(slug, "--", "-") - } - slug = strings.Trim(slug, "-") - if slug == "" { - if len(teamID) >= 8 { - return teamID[:8] - } - return teamID - } - return slug -} - -// ListResources implements dashboard.v1.DashboardService/ListResources. -func (s *Server) ListResources(ctx context.Context, req *dashboardv1.ListResourcesRequest) (*dashboardv1.ListResourcesResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - - rows, err := s.db.QueryContext(ctx, ` - SELECT id, token, resource_type, tier, status, name, storage_bytes, cloud_vendor, country_code, expires_at, created_at - FROM resources - WHERE team_id = $1 AND status != 'deleted' - ORDER BY created_at DESC - `, teamID) - if err != nil { - slog.Error("dashboardsvc.ListResources.query_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Internal, "list resources failed") - } - defer rows.Close() - - var out []*dashboardv1.DashboardResource - for rows.Next() { - var ( - id uuid.UUID - token uuid.UUID - resType, tier string - resStatus string - name sql.NullString - storageBytes int64 - cloudVendor sql.NullString - countryCode sql.NullString - expiresAt sql.NullTime - createdAt time.Time - ) - if err := rows.Scan(&id, &token, &resType, &tier, &resStatus, &name, &storageBytes, &cloudVendor, &countryCode, &expiresAt, &createdAt); err != nil { - slog.Error("dashboardsvc.ListResources.scan_failed", "error", err) - return nil, status.Error(codes.Internal, "list resources failed") - } - - limitMB := s.plans.StorageLimitMB(tier, resType) - _, storageExceeded, _ := quota.CheckStorageQuota(ctx, s.db, id, limitMB) - - dr := &dashboardv1.DashboardResource{ - Id: id.String(), - Token: token.String(), - ResourceType: resType, - Tier: tier, - Status: resStatus, - StorageBytes: storageBytes, - StorageExceeded: storageExceeded, - CreatedAt: createdAt.UTC().Format(time.RFC3339Nano), - } - if name.Valid { - dr.Name = name.String - } - if cloudVendor.Valid { - dr.CloudVendor = cloudVendor.String - } - if countryCode.Valid { - dr.CountryCode = countryCode.String - } - if expiresAt.Valid { - s := expiresAt.Time.UTC().Format(time.RFC3339Nano) - dr.ExpiresAt = &s - } - out = append(out, dr) - } - if err := rows.Err(); err != nil { - slog.Error("dashboardsvc.ListResources.rows_failed", "error", err) - return nil, status.Error(codes.Internal, "list resources failed") - } - - return &dashboardv1.ListResourcesResponse{ - Resources: out, - TotalCount: int64(len(out)), - }, nil -} - -// GetResource implements dashboard.v1.DashboardService/GetResource. -func (s *Server) GetResource(ctx context.Context, req *dashboardv1.GetResourceRequest) (*dashboardv1.GetResourceResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - token, err := uuid.Parse(req.GetToken()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid token") - } - - var ( - id uuid.UUID - tokenDB uuid.UUID - resType, tier string - resStatus string - name sql.NullString - storageBytes int64 - cloudVendor sql.NullString - countryCode sql.NullString - expiresAt sql.NullTime - createdAt time.Time - connEnc sql.NullString - ) - - err = s.db.QueryRowContext(ctx, ` - SELECT id, token, resource_type, tier, status, name, storage_bytes, cloud_vendor, country_code, expires_at, created_at, connection_url - FROM resources - WHERE token = $1 AND team_id = $2 - `, token, teamID).Scan( - &id, &tokenDB, &resType, &tier, &resStatus, &name, &storageBytes, &cloudVendor, &countryCode, &expiresAt, &createdAt, &connEnc, - ) - if err == sql.ErrNoRows { - return nil, status.Error(codes.NotFound, "resource not found") - } - if err != nil { - slog.Error("dashboardsvc.GetResource.query_failed", "error", err) - return nil, status.Error(codes.Internal, "get resource failed") - } - - limitMB := s.plans.StorageLimitMB(tier, resType) - _, storageExceeded, _ := quota.CheckStorageQuota(ctx, s.db, id, limitMB) - - dr := &dashboardv1.DashboardResource{ - Id: id.String(), - Token: tokenDB.String(), - ResourceType: resType, - Tier: tier, - Status: resStatus, - StorageBytes: storageBytes, - StorageExceeded: storageExceeded, - CreatedAt: createdAt.UTC().Format(time.RFC3339Nano), - } - if name.Valid { - dr.Name = name.String - } - if cloudVendor.Valid { - dr.CloudVendor = cloudVendor.String - } - if countryCode.Valid { - dr.CountryCode = countryCode.String - } - if expiresAt.Valid { - s := expiresAt.Time.UTC().Format(time.RFC3339Nano) - dr.ExpiresAt = &s - } - - if connEnc.Valid && connEnc.String != "" { - aesKey, kerr := crypto.ParseAESKey(s.cfg.AESKey) - if kerr != nil { - slog.Error("dashboardsvc.GetResource.aes_key_invalid", "error", kerr) - return nil, status.Error(codes.Internal, "encryption configuration error") - } - plain, derr := crypto.Decrypt(aesKey, connEnc.String) - if derr != nil { - slog.Error("dashboardsvc.GetResource.decrypt_failed", "error", derr) - return nil, status.Error(codes.Internal, "decrypt connection_url failed") - } - dr.ConnectionUrl = plain - } - - return &dashboardv1.GetResourceResponse{Resource: dr}, nil -} - -// DeleteResource implements dashboard.v1.DashboardService/DeleteResource. -func (s *Server) DeleteResource(ctx context.Context, req *dashboardv1.DeleteResourceRequest) (*dashboardv1.DeleteResourceResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - token, err := uuid.Parse(req.GetToken()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid token") - } - - resource, err := models.GetResourceByToken(ctx, s.db, token) - if err != nil { - var notFound *models.ErrResourceNotFound - if errors.As(err, ¬Found) { - return nil, status.Error(codes.NotFound, "resource not found") - } - slog.Error("dashboardsvc.DeleteResource.lookup_failed", "error", err) - return nil, status.Error(codes.Internal, "get resource failed") - } - if !resource.TeamID.Valid || resource.TeamID.UUID != teamID { - return nil, status.Error(codes.NotFound, "resource not found") - } - - if err := models.SoftDeleteResource(ctx, s.db, resource.ID); err != nil { - slog.Error("dashboardsvc.DeleteResource.soft_delete_failed", "error", err, "resource_id", resource.ID) - return nil, status.Error(codes.Internal, "delete resource failed") - } - - switch resource.ResourceType { - case "storage": - if s.storageProvider != nil { - if deprovErr := s.storageProvider.Deprovision(ctx, token.String()); deprovErr != nil { - slog.Warn("dashboardsvc.DeleteResource.storage_deprovision_failed", - "error", deprovErr, "resource_id", resource.ID, "token", token.String()) - } - } - default: - if s.provisioner != nil { - resType := resourceTypeToProto(resource.ResourceType) - if resType != commonv1.ResourceType_RESOURCE_TYPE_UNSPECIFIED { - providerID := resource.ProviderResourceID.String - if deprovErr := s.provisioner.DeprovisionResource(ctx, token.String(), providerID, resType); deprovErr != nil { - slog.Warn("dashboardsvc.DeleteResource.deprovision_failed", - "error", deprovErr, "resource_id", resource.ID, "resource_type", resource.ResourceType) - } - } - } - } - - _ = s.rdb.Del(ctx, fmt.Sprintf("res:%s", token.String())) - - return &dashboardv1.DeleteResourceResponse{Ok: true}, nil -} - -// RotateCredentials implements dashboard.v1.DashboardService/RotateCredentials. -func (s *Server) RotateCredentials(ctx context.Context, req *dashboardv1.RotateCredentialsRequest) (*dashboardv1.RotateCredentialsResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - token, err := uuid.Parse(req.GetToken()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid token") - } - - resource, err := models.GetResourceByToken(ctx, s.db, token) - if err != nil { - var notFound *models.ErrResourceNotFound - if errors.As(err, ¬Found) { - return nil, status.Error(codes.NotFound, "resource not found") - } - slog.Error("dashboardsvc.RotateCredentials.lookup_failed", "error", err) - return nil, status.Error(codes.Internal, "get resource failed") - } - if !resource.TeamID.Valid || resource.TeamID.UUID != teamID { - return nil, status.Error(codes.NotFound, "resource not found") - } - - if !resource.ConnectionURL.Valid || resource.ConnectionURL.String == "" { - return nil, status.Error(codes.FailedPrecondition, "resource has no connection_url") - } - - aesKey, err := crypto.ParseAESKey(s.cfg.AESKey) - if err != nil { - slog.Error("dashboardsvc.RotateCredentials.aes_key_invalid", "error", err) - return nil, status.Error(codes.Internal, "encryption configuration error") - } - - plainURL, err := crypto.Decrypt(aesKey, resource.ConnectionURL.String) - if err != nil { - slog.Error("dashboardsvc.RotateCredentials.decrypt_failed", "error", err) - return nil, status.Error(codes.Internal, "decrypt connection_url failed") - } - - pwBytes := make([]byte, 16) - if _, err := rand.Read(pwBytes); err != nil { - return nil, status.Error(codes.Internal, "generate password failed") - } - newPassword := hex.EncodeToString(pwBytes) - - parsed, err := url.Parse(plainURL) - if err != nil { - slog.Error("dashboardsvc.RotateCredentials.url_parse_failed", "error", err) - return nil, status.Error(codes.Internal, "parse connection_url failed") - } - username := parsed.User.Username() - parsed.User = url.UserPassword(username, newPassword) - newPlainURL := parsed.String() - - applyRotatedPassword(ctx, s.cfg, resource, username, newPassword, plainURL) - - newEncryptedURL, err := crypto.Encrypt(aesKey, newPlainURL) - if err != nil { - slog.Error("dashboardsvc.RotateCredentials.encrypt_failed", "error", err) - return nil, status.Error(codes.Internal, "encrypt connection_url failed") - } - - if err := models.UpdateConnectionURL(ctx, s.db, resource.ID, newEncryptedURL); err != nil { - slog.Error("dashboardsvc.RotateCredentials.update_failed", "error", err) - return nil, status.Error(codes.Internal, "persist rotated credentials failed") - } - - limitMB := s.plans.StorageLimitMB(resource.Tier, resource.ResourceType) - _, storageExceeded, _ := quota.CheckStorageQuota(ctx, s.db, resource.ID, limitMB) - - resProto := &dashboardv1.DashboardResource{ - Id: resource.ID.String(), - Token: resource.Token.String(), - ResourceType: resource.ResourceType, - Tier: resource.Tier, - Status: resource.Status, - StorageBytes: resource.StorageBytes, - StorageExceeded: storageExceeded, - CreatedAt: resource.CreatedAt.UTC().Format(time.RFC3339Nano), - } - if resource.Name.Valid { - resProto.Name = resource.Name.String - } - if resource.CloudVendor.Valid { - resProto.CloudVendor = resource.CloudVendor.String - } - if resource.CountryCode.Valid { - resProto.CountryCode = resource.CountryCode.String - } - if resource.ExpiresAt.Valid { - s := resource.ExpiresAt.Time.UTC().Format(time.RFC3339Nano) - resProto.ExpiresAt = &s - } - - return &dashboardv1.RotateCredentialsResponse{ - ConnectionUrl: newPlainURL, - Resource: resProto, - }, nil -} - -// GetTeam implements dashboard.v1.DashboardService/GetTeam. -func (s *Server) GetTeam(ctx context.Context, req *dashboardv1.GetTeamRequest) (*dashboardv1.GetTeamResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - - team, err := s.loadDashboardTeam(ctx, teamID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, status.Error(codes.NotFound, "team not found") - } - slog.Error("dashboardsvc.GetTeam.query_failed", "error", err) - return nil, status.Error(codes.Internal, "get team failed") - } - return &dashboardv1.GetTeamResponse{Team: team}, nil -} - -func (s *Server) loadDashboardTeam(ctx context.Context, teamID uuid.UUID) (*dashboardv1.DashboardTeam, error) { - var ( - id uuid.UUID - name sql.NullString - planTier string - createdAt time.Time - memberCnt int64 - ownerID sql.NullString - ) - err := s.db.QueryRowContext(ctx, ` - SELECT t.id, t.name, t.plan_tier, t.created_at, - (SELECT COUNT(*) FROM users WHERE team_id = t.id), - COALESCE( - (SELECT id::text FROM users WHERE team_id = t.id AND role = 'owner' ORDER BY created_at ASC LIMIT 1), - (SELECT id::text FROM users WHERE team_id = t.id ORDER BY created_at ASC LIMIT 1) - ) - FROM teams t - WHERE t.id = $1 - `, teamID).Scan(&id, &name, &planTier, &createdAt, &memberCnt, &ownerID) - if err != nil { - return nil, err - } - nameStr := "" - if name.Valid { - nameStr = name.String - } - tidStr := id.String() - ownerStr := "" - if ownerID.Valid { - ownerStr = ownerID.String - } - return &dashboardv1.DashboardTeam{ - Id: tidStr, - Name: nameStr, - Slug: slugify(nameStr, tidStr), - OwnerId: ownerStr, - MemberCount: int32(memberCnt), - Tier: planTier, - CreatedAt: createdAt.UTC().Format(time.RFC3339Nano), - }, nil -} - -// UpdateTeam implements dashboard.v1.DashboardService/UpdateTeam. -func (s *Server) UpdateTeam(ctx context.Context, req *dashboardv1.UpdateTeamRequest) (*dashboardv1.UpdateTeamResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - name := strings.TrimSpace(req.GetName()) - if name == "" { - return nil, status.Error(codes.InvalidArgument, "name required") - } - - _, err := s.db.ExecContext(ctx, `UPDATE teams SET name = $1 WHERE id = $2`, name, teamID) - if err != nil { - slog.Error("dashboardsvc.UpdateTeam.exec_failed", "error", err) - return nil, status.Error(codes.Internal, "update team failed") - } - - team, err := s.loadDashboardTeam(ctx, teamID) - if err != nil { - slog.Error("dashboardsvc.UpdateTeam.reload_failed", "error", err) - return nil, status.Error(codes.Internal, "load team failed") - } - return &dashboardv1.UpdateTeamResponse{Team: team}, nil -} - -// GetBilling implements dashboard.v1.DashboardService/GetBilling. -func (s *Server) GetBilling(ctx context.Context, req *dashboardv1.GetBillingRequest) (*dashboardv1.GetBillingResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - - var planTier string - var subID sql.NullString - err := s.db.QueryRowContext(ctx, ` - SELECT plan_tier, stripe_customer_id FROM teams WHERE id = $1 - `, teamID).Scan(&planTier, &subID) - if err == sql.ErrNoRows { - return nil, status.Error(codes.NotFound, "team not found") - } - if err != nil { - slog.Error("dashboardsvc.GetBilling.query_failed", "error", err) - return nil, status.Error(codes.Internal, "get billing failed") - } - - rzpOK := s.cfg.RazorpayKeyID != "" && s.cfg.RazorpayKeySecret != "" - - billingStatus := "none" - sid := "" - if subID.Valid { - sid = strings.TrimSpace(subID.String) - } - if sid != "" { - billingStatus = "active" - } - - info := &dashboardv1.BillingInfo{ - Plan: planTier, - Status: billingStatus, - RazorpayConfigured: rzpOK, - } - - if sid != "" && rzpOK { - portal := &razorpaybilling.Portal{DB: s.db, Cfg: s.cfg} - details, derr := portal.FetchSubscriptionDetails(sid) - if derr != nil { - slog.Warn("dashboardsvc.GetBilling.rzp_fetch_failed", "error", derr, "team_id", teamID) - } else if details != nil { - ss := details.Status - info.SubscriptionStatus = &ss - if !details.CurrentPeriodEnd.IsZero() { - pe := details.CurrentPeriodEnd.UTC().Format(time.RFC3339Nano) - info.CurrentPeriodEnd = &pe - } - if details.PaymentLast4 != "" { - l4 := details.PaymentLast4 - info.PaymentLast4 = &l4 - } - if details.PaymentNetwork != "" { - net := details.PaymentNetwork - info.PaymentNetwork = &net - } - if details.PaymentExpMonth > 0 { - m := details.PaymentExpMonth - info.PaymentExpMonth = &m - } - if details.PaymentExpYear > 0 { - y := details.PaymentExpYear - info.PaymentExpYear = &y - } - if details.CancelAtPeriodEnd { - ce := true - info.CancelAtPeriodEnd = &ce - } - switch strings.ToLower(details.Status) { - case "cancelled", "completed", "expired": - info.Status = details.Status - case "halted": - info.Status = "halted" - case "pending", "authenticated": - info.Status = "pending_payment" - default: - info.Status = "active" - } - } - } - - return &dashboardv1.GetBillingResponse{Billing: info}, nil -} - -func (s *Server) razorpayPlanIDs() map[string]string { - m := make(map[string]string) - if s.cfg.RazorpayPlanIDHobby != "" { - m["hobby"] = s.cfg.RazorpayPlanIDHobby - } - if s.cfg.RazorpayPlanIDPro != "" { - m["pro"] = s.cfg.RazorpayPlanIDPro - } - if s.cfg.RazorpayPlanIDTeam != "" { - m["team"] = s.cfg.RazorpayPlanIDTeam - } - return m -} - -// CreateCheckout implements dashboard.v1.DashboardService/CreateCheckout. -func (s *Server) CreateCheckout(ctx context.Context, req *dashboardv1.CreateCheckoutRequest) (*dashboardv1.CreateCheckoutResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - - planKey := strings.ToLower(strings.TrimSpace(req.GetPlan())) - planIDs := s.razorpayPlanIDs() - planID, ok := planIDs[planKey] - if !ok { - return nil, status.Error(codes.InvalidArgument, "plan must be hobby, pro, or team") - } - - if s.cfg.RazorpayKeyID == "" || s.cfg.RazorpayKeySecret == "" { - return nil, status.Error(codes.FailedPrecondition, "billing_not_configured") - } - - client := razorpay.NewClient(s.cfg.RazorpayKeyID, s.cfg.RazorpayKeySecret) - subBody := map[string]interface{}{ - "plan_id": planID, - "total_count": 120, - "quantity": 1, - "customer_notify": 1, - "notes": map[string]interface{}{ - "team_id": teamID.String(), - "plan": planKey, - }, - } - - sub, err := client.Subscription.Create(subBody, nil) - if err != nil { - slog.Error("dashboardsvc.CreateCheckout.subscription_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Unavailable, "razorpay subscription create failed") - } - - if subID, ok := sub["id"].(string); ok && subID != "" { - if updateErr := models.UpdateRazorpaySubscriptionID(ctx, s.db, teamID, subID); updateErr != nil { - slog.Error("dashboardsvc.CreateCheckout.persist_sub_id_failed", "error", updateErr, "team_id", teamID) - } - } - - shortURL, _ := sub["short_url"].(string) - subscriptionID, _ := sub["id"].(string) - - return &dashboardv1.CreateCheckoutResponse{ - ShortUrl: shortURL, - SubscriptionId: subscriptionID, - }, nil -} - -// CancelSubscription implements dashboard.v1.DashboardService/CancelSubscription. -func (s *Server) CancelSubscription(ctx context.Context, req *dashboardv1.CancelSubscriptionRequest) (*dashboardv1.CancelSubscriptionResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - if s.cfg.RazorpayKeyID == "" || s.cfg.RazorpayKeySecret == "" { - return nil, status.Error(codes.FailedPrecondition, "billing_not_configured") - } - portal := &razorpaybilling.Portal{DB: s.db, Cfg: s.cfg} - subID, err := portal.SubscriptionID(ctx, teamID) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - if err := portal.CancelAtCycleEnd(subID); err != nil { - slog.Error("dashboardsvc.CancelSubscription.rzp_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Unavailable, "razorpay cancel failed") - } - return &dashboardv1.CancelSubscriptionResponse{Ok: true, CancelledAtCycleEnd: true}, nil -} - -// ListInvoices implements dashboard.v1.DashboardService/ListInvoices. -func (s *Server) ListInvoices(ctx context.Context, req *dashboardv1.ListInvoicesRequest) (*dashboardv1.ListInvoicesResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - if s.cfg.RazorpayKeyID == "" || s.cfg.RazorpayKeySecret == "" { - return nil, status.Error(codes.FailedPrecondition, "billing_not_configured") - } - portal := &razorpaybilling.Portal{DB: s.db, Cfg: s.cfg} - subID, err := portal.SubscriptionID(ctx, teamID) - if err != nil { - return &dashboardv1.ListInvoicesResponse{}, nil - } - rows, err := portal.ListSubscriptionInvoices(subID) - if err != nil { - slog.Error("dashboardsvc.ListInvoices.rzp_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Unavailable, "razorpay invoice list failed") - } - out := make([]*dashboardv1.InvoiceRow, 0, len(rows)) - for _, r := range rows { - out = append(out, &dashboardv1.InvoiceRow{ - Id: r.ID, - Amount: r.Amount, - Currency: r.Currency, - Status: r.Status, - Date: r.Date.UTC().Format(time.RFC3339Nano), - PdfUrl: r.PDFURL, - }) - } - return &dashboardv1.ListInvoicesResponse{Invoices: out}, nil -} - -// UpdatePaymentMethod implements dashboard.v1.DashboardService/UpdatePaymentMethod. -func (s *Server) UpdatePaymentMethod(ctx context.Context, req *dashboardv1.UpdatePaymentMethodRequest) (*dashboardv1.UpdatePaymentMethodResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - if s.cfg.RazorpayKeyID == "" || s.cfg.RazorpayKeySecret == "" { - return nil, status.Error(codes.FailedPrecondition, "billing_not_configured") - } - portal := &razorpaybilling.Portal{DB: s.db, Cfg: s.cfg} - subID, err := portal.SubscriptionID(ctx, teamID) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - shortURL, err := portal.PaymentUpdateURL(subID) - if err != nil { - slog.Warn("dashboardsvc.UpdatePaymentMethod.failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - return &dashboardv1.UpdatePaymentMethodResponse{ShortUrl: shortURL}, nil -} - -// ChangePlan implements dashboard.v1.DashboardService/ChangePlan. -func (s *Server) ChangePlan(ctx context.Context, req *dashboardv1.ChangePlanRequest) (*dashboardv1.ChangePlanResponse, error) { - if _, err := s.requireMatchingTeam(ctx, req.GetTeamId()); err != nil { - return nil, err - } - teamID, _ := uuid.Parse(req.GetTeamId()) - if s.cfg.RazorpayKeyID == "" || s.cfg.RazorpayKeySecret == "" { - return nil, status.Error(codes.FailedPrecondition, "billing_not_configured") - } - target := strings.ToLower(strings.TrimSpace(req.GetTargetPlan())) - var planTier string - err := s.db.QueryRowContext(ctx, `SELECT plan_tier FROM teams WHERE id = $1`, teamID).Scan(&planTier) - if err == sql.ErrNoRows { - return nil, status.Error(codes.NotFound, "team not found") - } - if err != nil { - return nil, status.Error(codes.Internal, "load team failed") - } - if strings.EqualFold(strings.TrimSpace(planTier), target) { - return nil, status.Error(codes.InvalidArgument, "already on requested plan") - } - planIDs := s.razorpayPlanIDs() - if _, ok := planIDs[target]; !ok { - return nil, status.Error(codes.InvalidArgument, "plan must be hobby, pro, or team") - } - portal := &razorpaybilling.Portal{DB: s.db, Cfg: s.cfg} - if _, err := portal.SubscriptionID(ctx, teamID); err != nil { - return nil, status.Error(codes.InvalidArgument, "no active subscription to change") - } - res, err := portal.ChangePlan(ctx, teamID, target, planIDs) - if err != nil { - slog.Error("dashboardsvc.ChangePlan.failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Unavailable, err.Error()) - } - return &dashboardv1.ChangePlanResponse{ - Ok: true, - NewPlan: res.NewPlan, - EffectiveDate: res.EffectiveDate.UTC().Format(time.RFC3339Nano), - CheckoutShortUrl: res.CheckoutShort, - }, nil -} diff --git a/internal/dashboardsvc/server_test.go b/internal/dashboardsvc/server_test.go deleted file mode 100644 index 66af29c..0000000 --- a/internal/dashboardsvc/server_test.go +++ /dev/null @@ -1,617 +0,0 @@ -package dashboardsvc - -import ( - "context" - "database/sql" - "net" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/alicebob/miniredis/v2" - "github.com/google/uuid" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" - "google.golang.org/grpc/test/bufconn" - - "instant.dev/internal/config" - "instant.dev/internal/crypto" - "instant.dev/internal/plans" - "instant.dev/internal/providers/compute/noop" - "instant.dev/internal/testhelpers" - dashboardv1 "instant.dev/proto/dashboard/v1" -) - -func newTestRedis(t *testing.T) *redis.Client { - t.Helper() - s, err := miniredis.Run() - require.NoError(t, err) - t.Cleanup(s.Close) - return redis.NewClient(&redis.Options{Addr: s.Addr()}) -} - -func testCfg() *config.Config { - return &config.Config{ - JWTSecret: testhelpers.TestJWTSecret, - AESKey: testhelpers.TestAESKeyHex, - CustomerDatabaseURL: "", - MongoAdminURI: "", - RazorpayKeyID: "", - RazorpayKeySecret: "", - RazorpayPlanIDHobby: "plan_hobby", - RazorpayPlanIDPro: "plan_pro", - RazorpayPlanIDTeam: "plan_team", - } -} - -func dialDashboardGRPC(t *testing.T, srv *Server) (dashboardv1.DashboardServiceClient, func()) { - t.Helper() - lis := bufconn.Listen(1024 * 1024) - grpcSrv := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(testhelpers.TestJWTSecret))) - dashboardv1.RegisterDashboardServiceServer(grpcSrv, srv) - go func() { _ = grpcSrv.Serve(lis) }() - conn, err := grpc.DialContext(context.Background(), "bufnet", - grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { - return lis.Dial() - }), - grpc.WithTransportCredentials(insecure.NewCredentials())) - require.NoError(t, err) - cl := dashboardv1.NewDashboardServiceClient(conn) - return cl, func() { - _ = conn.Close() - grpcSrv.Stop() - } -} - -func grpcAuthCtx(t *testing.T, teamID, userID uuid.UUID) context.Context { - t.Helper() - tok := testhelpers.MustSignSessionJWT(t, userID.String(), teamID.String(), "u@example.com") - return metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+tok)) -} - -// resourceSelectColumns mirrors models.resourceColumns. Keep in sync — -// out-of-date column list here surfaces as "sql: expected N destination -// arguments in Scan, not M" at runtime. -func resourceSelectColumns() *sqlmock.Rows { - return sqlmock.NewRows([]string{ - "id", "team_id", "token", "resource_type", "name", "connection_url", "key_prefix", "tier", - "env", - "fingerprint", "cloud_vendor", "country_code", "status", "migration_status", - "expires_at", "storage_bytes", "provider_resource_id", "created_request_id", "created_at", - }) -} - -func TestListResources_Success_AndStorageExceeded(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - resID := uuid.New() - tok := uuid.New() - created := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC) - // anonymous postgres limit is 10MB in plans.Default — exceed with bytes - storageBytes := int64(11 * 1024 * 1024) - - mock.ExpectQuery(`SELECT id, token, resource_type, tier, status, name, storage_bytes`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{ - "id", "token", "resource_type", "tier", "status", "name", "storage_bytes", - "cloud_vendor", "country_code", "expires_at", "created_at", - }).AddRow(resID, tok, "postgres", "anonymous", "active", "db1", storageBytes, "aws", "US", nil, created)) - - mock.ExpectQuery(`SELECT storage_bytes FROM resources WHERE id`). - WithArgs(resID). - WillReturnRows(sqlmock.NewRows([]string{"storage_bytes"}).AddRow(storageBytes)) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - out, err := client.ListResources(ctx, &dashboardv1.ListResourcesRequest{TeamId: teamID.String()}) - require.NoError(t, err) - require.Len(t, out.Resources, 1) - require.Equal(t, int64(1), out.TotalCount) - r := out.Resources[0] - require.Equal(t, resID.String(), r.Id) - require.Equal(t, tok.String(), r.Token) - require.Equal(t, "postgres", r.ResourceType) - require.True(t, r.StorageExceeded) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestListResources_TeamMismatch(t *testing.T) { - t.Parallel() - db, _, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamJWT := uuid.New() - otherTeam := uuid.New() - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamJWT, uuid.New()) - _, err = client.ListResources(ctx, &dashboardv1.ListResourcesRequest{TeamId: otherTeam.String()}) - require.Error(t, err) - require.Equal(t, codes.PermissionDenied, status.Code(err)) -} - -func TestListResources_Unauthenticated(t *testing.T) { - t.Parallel() - db, _, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - _, err = client.ListResources(context.Background(), &dashboardv1.ListResourcesRequest{TeamId: uuid.New().String()}) - require.Error(t, err) - require.Equal(t, codes.Unauthenticated, status.Code(err)) -} - -func TestGetResource_Success_WithConnectionURL(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - resID := uuid.New() - tok := uuid.New() - created := time.Now().UTC().Truncate(time.Second) - aesKey, err := crypto.ParseAESKey(testhelpers.TestAESKeyHex) - require.NoError(t, err) - enc, err := crypto.Encrypt(aesKey, "postgres://u:pw@localhost:5432/db") - require.NoError(t, err) - - mock.ExpectQuery(`SELECT id, token, resource_type, tier, status, name, storage_bytes, cloud_vendor, country_code, expires_at, created_at, connection_url`). - WithArgs(tok, teamID). - WillReturnRows(sqlmock.NewRows([]string{ - "id", "token", "resource_type", "tier", "status", "name", "storage_bytes", - "cloud_vendor", "country_code", "expires_at", "created_at", "connection_url", - }).AddRow(resID, tok, "postgres", "hobby", "active", "n1", int64(100), nil, nil, nil, created, enc)) - - mock.ExpectQuery(`SELECT storage_bytes FROM resources WHERE id`). - WithArgs(resID). - WillReturnRows(sqlmock.NewRows([]string{"storage_bytes"}).AddRow(int64(100))) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - out, err := client.GetResource(ctx, &dashboardv1.GetResourceRequest{Token: tok.String(), TeamId: teamID.String()}) - require.NoError(t, err) - require.Contains(t, out.Resource.ConnectionUrl, "postgres://") - require.Equal(t, tok.String(), out.Resource.Token) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestGetResource_NotFound_EmptyResult(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - tok := uuid.New() - - mock.ExpectQuery(`SELECT id, token, resource_type`). - WithArgs(tok, teamID). - WillReturnRows(sqlmock.NewRows([]string{ - "id", "token", "resource_type", "tier", "status", "name", "storage_bytes", - "cloud_vendor", "country_code", "expires_at", "created_at", "connection_url", - })) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.GetResource(ctx, &dashboardv1.GetResourceRequest{Token: tok.String(), TeamId: teamID.String()}) - require.Error(t, err) - require.Equal(t, codes.NotFound, status.Code(err)) -} - -func TestDeleteResource_Success(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - resID := uuid.New() - tok := uuid.New() - created := time.Now() - - rows := resourceSelectColumns().AddRow( - resID, teamID, tok, "webhook", nil, nil, nil, "hobby", - "production", // env (added by migration 009; column at position 9 in resourceSelectColumns) - nil, nil, nil, "active", nil, - nil, int64(0), nil, nil, created, - ) - mock.ExpectQuery(`FROM resources WHERE token`). - WithArgs(tok). - WillReturnRows(rows) - - mock.ExpectExec(`UPDATE resources SET status = 'deleted'`). - WithArgs(resID). - WillReturnResult(sqlmock.NewResult(0, 1)) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - out, err := client.DeleteResource(ctx, &dashboardv1.DeleteResourceRequest{Token: tok.String(), TeamId: teamID.String()}) - require.NoError(t, err) - require.True(t, out.Ok) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestDeleteResource_NotFound(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - tok := uuid.New() - - mock.ExpectQuery(`FROM resources WHERE token`). - WithArgs(tok). - WillReturnError(sql.ErrNoRows) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.DeleteResource(ctx, &dashboardv1.DeleteResourceRequest{Token: tok.String(), TeamId: teamID.String()}) - require.Error(t, err) - require.Equal(t, codes.NotFound, status.Code(err)) -} - -func TestRotateCredentials_Success(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - resID := uuid.New() - tok := uuid.New() - created := time.Now() - aesKey, err := crypto.ParseAESKey(testhelpers.TestAESKeyHex) - require.NoError(t, err) - enc, err := crypto.Encrypt(aesKey, "nats://usr:oldsecret@127.0.0.1:4222") - require.NoError(t, err) - - rows := resourceSelectColumns().AddRow( - resID, teamID, tok, "queue", nil, enc, nil, "hobby", - "production", // env - nil, nil, nil, "active", nil, - nil, int64(0), nil, nil, created, - ) - mock.ExpectQuery(`FROM resources WHERE token`). - WithArgs(tok). - WillReturnRows(rows) - - mock.ExpectExec(`UPDATE resources SET connection_url`). - WithArgs(sqlmock.AnyArg(), resID). - WillReturnResult(sqlmock.NewResult(0, 1)) - - mock.ExpectQuery(`SELECT storage_bytes FROM resources WHERE id`). - WithArgs(resID). - WillReturnRows(sqlmock.NewRows([]string{"storage_bytes"}).AddRow(int64(0))) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - out, err := client.RotateCredentials(ctx, &dashboardv1.RotateCredentialsRequest{Token: tok.String(), TeamId: teamID.String()}) - require.NoError(t, err) - require.NotEmpty(t, out.ConnectionUrl) - require.Contains(t, out.ConnectionUrl, "nats://") - require.NotEmpty(t, out.Resource) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestRotateCredentials_NoConnectionURL(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - resID := uuid.New() - tok := uuid.New() - created := time.Now() - - rows := resourceSelectColumns().AddRow( - resID, teamID, tok, "queue", nil, nil, nil, "hobby", - "production", // env - nil, nil, nil, "active", nil, - nil, int64(0), nil, nil, created, - ) - mock.ExpectQuery(`FROM resources WHERE token`). - WithArgs(tok). - WillReturnRows(rows) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.RotateCredentials(ctx, &dashboardv1.RotateCredentialsRequest{Token: tok.String(), TeamId: teamID.String()}) - require.Error(t, err) - require.Equal(t, codes.FailedPrecondition, status.Code(err)) -} - -func TestGetTeam_Success(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - owner := uuid.New() - created := time.Now().UTC().Truncate(time.Second) - - mock.ExpectQuery(`FROM teams t`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "plan_tier", "created_at", "count", "owner"}). - AddRow(teamID, "My Team", "pro", created, int64(3), owner.String())) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, owner) - out, err := client.GetTeam(ctx, &dashboardv1.GetTeamRequest{TeamId: teamID.String(), UserId: owner.String()}) - require.NoError(t, err) - require.Equal(t, teamID.String(), out.Team.Id) - require.Equal(t, "My Team", out.Team.Name) - require.Equal(t, "my-team", out.Team.Slug) - require.Equal(t, int32(3), out.Team.MemberCount) - require.Equal(t, owner.String(), out.Team.OwnerId) -} - -func TestGetTeam_UserMismatch(t *testing.T) { - t.Parallel() - db, _, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - jwtUser := uuid.New() - otherUser := uuid.New() - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, jwtUser) - _, err = client.GetTeam(ctx, &dashboardv1.GetTeamRequest{TeamId: teamID.String(), UserId: otherUser.String()}) - require.Error(t, err) - require.Equal(t, codes.PermissionDenied, status.Code(err)) -} - -func TestUpdateTeam_Success(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - userID := uuid.New() - created := time.Now().UTC().Truncate(time.Second) - - mock.ExpectExec(`UPDATE teams SET name`). - WithArgs("New Name", teamID). - WillReturnResult(sqlmock.NewResult(0, 1)) - - mock.ExpectQuery(`FROM teams t`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "plan_tier", "created_at", "count", "owner"}). - AddRow(teamID, "New Name", "hobby", created, int64(1), userID.String())) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, userID) - out, err := client.UpdateTeam(ctx, &dashboardv1.UpdateTeamRequest{ - TeamId: teamID.String(), UserId: userID.String(), Name: "New Name", - }) - require.NoError(t, err) - require.Equal(t, "New Name", out.Team.Name) - require.Equal(t, "new-name", out.Team.Slug) -} - -func TestUpdateTeam_EmptyName(t *testing.T) { - t.Parallel() - db, _, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - userID := uuid.New() - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, userID) - _, err = client.UpdateTeam(ctx, &dashboardv1.UpdateTeamRequest{ - TeamId: teamID.String(), UserId: userID.String(), Name: " ", - }) - require.Error(t, err) - require.Equal(t, codes.InvalidArgument, status.Code(err)) -} - -func TestGetBilling(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - mock.ExpectQuery(`SELECT plan_tier, stripe_customer_id FROM teams`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{"plan_tier", "stripe_customer_id"}).AddRow("pro", "sub_123")) - - cfg := testCfg() - cfg.RazorpayKeyID = "key" - cfg.RazorpayKeySecret = "secret" - srv := NewServer(db, newTestRedis(t), cfg, plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - out, err := client.GetBilling(ctx, &dashboardv1.GetBillingRequest{TeamId: teamID.String()}) - require.NoError(t, err) - require.Equal(t, "pro", out.Billing.Plan) - require.Equal(t, "active", out.Billing.Status) - require.True(t, out.Billing.RazorpayConfigured) -} - -func TestCreateCheckout_NotConfigured(t *testing.T) { - t.Parallel() - db, _, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.CreateCheckout(ctx, &dashboardv1.CreateCheckoutRequest{TeamId: teamID.String(), Plan: "pro"}) - require.Error(t, err) - require.Equal(t, codes.FailedPrecondition, status.Code(err)) - require.Contains(t, err.Error(), "billing_not_configured") -} - -func TestCreateCheckout_InvalidPlan(t *testing.T) { - t.Parallel() - db, _, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - cfg := testCfg() - cfg.RazorpayKeyID = "k" - cfg.RazorpayKeySecret = "s" - srv := NewServer(db, newTestRedis(t), cfg, plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.CreateCheckout(ctx, &dashboardv1.CreateCheckoutRequest{TeamId: teamID.String(), Plan: "enterprise"}) - require.Error(t, err) - require.Equal(t, codes.InvalidArgument, status.Code(err)) -} - -func TestGetResource_InvalidToken(t *testing.T) { - t.Parallel() - db, _, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.GetResource(ctx, &dashboardv1.GetResourceRequest{Token: "not-uuid", TeamId: teamID.String()}) - require.Error(t, err) - require.Equal(t, codes.InvalidArgument, status.Code(err)) -} - -func TestListResources_ScopedQueryUsesTeamArg(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - rid := uuid.New() - created := time.Now() - - mock.ExpectQuery(`FROM resources`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{ - "id", "token", "resource_type", "tier", "status", "name", "storage_bytes", - "cloud_vendor", "country_code", "expires_at", "created_at", - }).AddRow(rid, uuid.New(), "redis", "hobby", "active", "r1", int64(0), nil, nil, nil, created)) - - mock.ExpectQuery(`SELECT storage_bytes FROM resources WHERE id`). - WithArgs(rid). - WillReturnRows(sqlmock.NewRows([]string{"storage_bytes"}).AddRow(int64(0))) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.ListResources(ctx, &dashboardv1.ListResourcesRequest{TeamId: teamID.String()}) - require.NoError(t, err) - require.NoError(t, mock.ExpectationsWereMet()) -} - -func TestGetTeam_NotFound(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - userID := uuid.New() - - mock.ExpectQuery(`FROM teams t`). - WithArgs(teamID). - WillReturnError(sql.ErrNoRows) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, userID) - _, err = client.GetTeam(ctx, &dashboardv1.GetTeamRequest{TeamId: teamID.String(), UserId: userID.String()}) - require.Error(t, err) - require.Equal(t, codes.NotFound, status.Code(err)) -} - -func TestGetBilling_TeamNotFound(t *testing.T) { - t.Parallel() - db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) - require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) - - teamID := uuid.New() - mock.ExpectQuery(`SELECT plan_tier`). - WithArgs(teamID). - WillReturnError(sql.ErrNoRows) - - srv := NewServer(db, newTestRedis(t), testCfg(), plans.Default(), nil, nil, nil, noop.NewStack()) - client, cleanup := dialDashboardGRPC(t, srv) - defer cleanup() - - ctx := grpcAuthCtx(t, teamID, uuid.New()) - _, err = client.GetBilling(ctx, &dashboardv1.GetBillingRequest{TeamId: teamID.String()}) - require.Error(t, err) - require.Equal(t, codes.NotFound, status.Code(err)) -} diff --git a/internal/dashboardsvc/stacks.go b/internal/dashboardsvc/stacks.go deleted file mode 100644 index 71d4fc1..0000000 --- a/internal/dashboardsvc/stacks.go +++ /dev/null @@ -1,172 +0,0 @@ -package dashboardsvc - -import ( - "context" - "errors" - "log/slog" - "strings" - "time" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "instant.dev/internal/models" - dashboardv1 "instant.dev/proto/dashboard/v1" -) - -func mapStackDisplayStatus(dbStatus string) string { - switch strings.ToLower(strings.TrimSpace(dbStatus)) { - case "healthy": - return "running" - case "failed": - return "failed" - case "stopped", "deleted", "deleting": - return "stopped" - default: - return "building" - } -} - -// pickPrimaryURLAndLogService chooses the best public URL and the service name -// used for log streaming (prefer exposed services with a URL). -func pickPrimaryURLAndLogService(svcs []*models.StackService) (url, logSvc string) { - var fallback *models.StackService - for _, ss := range svcs { - if ss.AppURL == "" { - continue - } - if ss.Expose { - return ss.AppURL, ss.Name - } - if fallback == nil { - fallback = ss - } - } - if fallback != nil { - return fallback.AppURL, fallback.Name - } - return "", "" -} - -func stackToDashboardProto(st *models.Stack, svcs []*models.StackService) *dashboardv1.DashboardStack { - url, logSvc := pickPrimaryURLAndLogService(svcs) - teamStr := "" - if st.TeamID != nil { - teamStr = st.TeamID.String() - } - return &dashboardv1.DashboardStack{ - Id: st.ID.String(), - Slug: st.Slug, - Name: st.Name, - Status: mapStackDisplayStatus(st.Status), - Url: url, - CreatedAt: st.CreatedAt.UTC().Format(time.RFC3339Nano), - TeamId: teamStr, - LogsService: logSvc, - } -} - -// ListStacks implements dashboard.v1.DashboardService/ListStacks. -func (s *Server) ListStacks(ctx context.Context, req *dashboardv1.ListStacksRequest) (*dashboardv1.ListStacksResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - - stacks, err := models.GetStacksByTeam(ctx, s.db, teamID) - if err != nil { - slog.Error("dashboardsvc.ListStacks.query_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Internal, "list stacks failed") - } - - out := make([]*dashboardv1.DashboardStack, 0, len(stacks)) - for _, st := range stacks { - svcs, svcErr := models.GetStackServicesByStack(ctx, s.db, st.ID) - if svcErr != nil { - slog.Error("dashboardsvc.ListStacks.services_failed", "error", svcErr, "stack_id", st.ID) - return nil, status.Error(codes.Internal, "list stacks failed") - } - out = append(out, stackToDashboardProto(st, svcs)) - } - - return &dashboardv1.ListStacksResponse{ - Stacks: out, - Total: int64(len(out)), - }, nil -} - -// GetStack implements dashboard.v1.DashboardService/GetStack. -func (s *Server) GetStack(ctx context.Context, req *dashboardv1.GetStackRequest) (*dashboardv1.GetStackResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - - slug := strings.TrimSpace(req.GetSlug()) - if slug == "" { - return nil, status.Error(codes.InvalidArgument, "slug required") - } - - stack, err := models.GetStackBySlug(ctx, s.db, slug) - if err != nil { - var notFound *models.ErrStackNotFound - if errors.As(err, ¬Found) { - return nil, status.Error(codes.NotFound, "stack not found") - } - slog.Error("dashboardsvc.GetStack.lookup_failed", "error", err, "slug", slug) - return nil, status.Error(codes.Internal, "get stack failed") - } - if stack.TeamID == nil || *stack.TeamID != teamID { - return nil, status.Error(codes.NotFound, "stack not found") - } - - svcs, err := models.GetStackServicesByStack(ctx, s.db, stack.ID) - if err != nil { - slog.Error("dashboardsvc.GetStack.services_failed", "error", err, "stack_id", stack.ID) - return nil, status.Error(codes.Internal, "get stack failed") - } - - return &dashboardv1.GetStackResponse{Stack: stackToDashboardProto(stack, svcs)}, nil -} - -// DeleteStack implements dashboard.v1.DashboardService/DeleteStack. -func (s *Server) DeleteStack(ctx context.Context, req *dashboardv1.DeleteStackRequest) (*dashboardv1.DeleteStackResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - - slug := strings.TrimSpace(req.GetSlug()) - if slug == "" { - return nil, status.Error(codes.InvalidArgument, "slug required") - } - - stack, err := models.GetStackBySlug(ctx, s.db, slug) - if err != nil { - var notFound *models.ErrStackNotFound - if errors.As(err, ¬Found) { - return nil, status.Error(codes.NotFound, "stack not found") - } - slog.Error("dashboardsvc.DeleteStack.lookup_failed", "error", err, "slug", slug) - return nil, status.Error(codes.Internal, "delete stack failed") - } - if stack.TeamID == nil || *stack.TeamID != teamID { - return nil, status.Error(codes.NotFound, "stack not found") - } - - if s.stackProv != nil { - if teardownErr := s.stackProv.TeardownStack(ctx, stack.Namespace); teardownErr != nil { - slog.Warn("dashboardsvc.DeleteStack.teardown_failed", - "slug", slug, "namespace", stack.Namespace, "error", teardownErr) - } - } else { - slog.Warn("dashboardsvc.DeleteStack.no_stack_provider", "slug", slug) - } - - if delErr := models.DeleteStack(ctx, s.db, stack.ID); delErr != nil { - slog.Error("dashboardsvc.DeleteStack.db_failed", "error", delErr, "stack_id", stack.ID) - return nil, status.Error(codes.Internal, "delete stack failed") - } - - return &dashboardv1.DeleteStackResponse{Ok: true}, nil -} diff --git a/internal/dashboardsvc/team_members.go b/internal/dashboardsvc/team_members.go deleted file mode 100644 index dc35f66..0000000 --- a/internal/dashboardsvc/team_members.go +++ /dev/null @@ -1,304 +0,0 @@ -package dashboardsvc - -import ( - "context" - "errors" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/google/uuid" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "instant.dev/internal/models" - dashboardv1 "instant.dev/proto/dashboard/v1" -) - -func teamMemberGRPCError(err error) error { - switch { - case errors.Is(err, models.ErrNotTeamOwner): - return status.Error(codes.PermissionDenied, err.Error()) - case errors.Is(err, models.ErrCannotRemoveOwner): - return status.Error(codes.FailedPrecondition, err.Error()) - case errors.Is(err, models.ErrOwnerCannotLeave): - return status.Error(codes.FailedPrecondition, err.Error()) - case errors.Is(err, models.ErrInvitationNotFound): - return status.Error(codes.NotFound, err.Error()) - case errors.Is(err, models.ErrInvitationExpired): - return status.Error(codes.FailedPrecondition, err.Error()) - case errors.Is(err, models.ErrInvitationNotPending): - return status.Error(codes.FailedPrecondition, err.Error()) - case errors.Is(err, models.ErrEmailMismatchInvite): - return status.Error(codes.PermissionDenied, err.Error()) - case errors.Is(err, models.ErrMemberLimitReached): - return status.Error(codes.ResourceExhausted, err.Error()) - case errors.Is(err, models.ErrAlreadyTeamMember): - return status.Error(codes.AlreadyExists, err.Error()) - case errors.Is(err, models.ErrInvalidInviteRole): - return status.Error(codes.InvalidArgument, err.Error()) - case errors.Is(err, models.ErrDuplicatePendingInvite): - return status.Error(codes.AlreadyExists, err.Error()) - default: - var notFound *models.ErrUserNotFound - if errors.As(err, ¬Found) { - return status.Error(codes.NotFound, notFound.Error()) - } - return status.Error(codes.Internal, err.Error()) - } -} - -func (s *Server) teamPlanTier(ctx context.Context, teamID uuid.UUID) (string, error) { - var tier string - err := s.db.QueryRowContext(ctx, `SELECT plan_tier FROM teams WHERE id = $1`, teamID).Scan(&tier) - if err != nil { - return "", err - } - return tier, nil -} - -func invitationToProto(inv *models.TeamInvitation) *dashboardv1.TeamInvitation { - if inv == nil { - return nil - } - return &dashboardv1.TeamInvitation{ - Id: inv.ID.String(), - Email: inv.Email, - Role: inv.Role, - Status: inv.Status, - InvitedBy: inv.InvitedBy.String(), - CreatedAt: inv.CreatedAt.UTC().Format(time.RFC3339Nano), - ExpiresAt: inv.ExpiresAt.UTC().Format(time.RFC3339Nano), - } -} - -func (s *Server) requireTeamOwner(ctx context.Context, teamID uuid.UUID) error { - authUser, err := authUserID(ctx) - if err != nil { - return err - } - role, err := models.GetUserRole(ctx, s.db, teamID, authUser) - if err != nil { - return status.Error(codes.Internal, "role lookup failed") - } - if role != "owner" { - return status.Error(codes.PermissionDenied, "owner only") - } - return nil -} - -// ListMembers implements dashboard.v1.DashboardService/ListMembers. -func (s *Server) ListMembers(ctx context.Context, req *dashboardv1.ListMembersRequest) (*dashboardv1.ListMembersResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - authUser, err := authUserID(ctx) - if err != nil { - return nil, err - } - role, err := models.GetUserRole(ctx, s.db, teamID, authUser) - if err != nil { - return nil, status.Error(codes.Internal, "role lookup failed") - } - if role != "owner" && role != "member" { - return nil, status.Error(codes.PermissionDenied, "not a member of this team") - } - - members, err := models.ListTeamMembers(ctx, s.db, teamID) - if err != nil { - slog.Error("dashboardsvc.ListMembers.query_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Internal, "list members failed") - } - tier, err := s.teamPlanTier(ctx, teamID) - if err != nil { - slog.Error("dashboardsvc.ListMembers.tier_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Internal, "team tier lookup failed") - } - limit := int32(s.plans.TeamMemberLimit(tier)) - - out := make([]*dashboardv1.TeamMember, 0, len(members)) - for _, m := range members { - out = append(out, &dashboardv1.TeamMember{ - Id: m.ID.String(), - Email: m.Email, - Role: m.Role, - CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339Nano), - }) - } - return &dashboardv1.ListMembersResponse{Members: out, MemberLimit: limit}, nil -} - -// InviteMember implements dashboard.v1.DashboardService/InviteMember. -func (s *Server) InviteMember(ctx context.Context, req *dashboardv1.InviteMemberRequest) (*dashboardv1.InviteMemberResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - if err := s.requireTeamOwner(ctx, teamID); err != nil { - return nil, err - } - authUser, err := authUserID(ctx) - if err != nil { - return nil, err - } - - role := strings.TrimSpace(strings.ToLower(req.GetRole())) - if role == "" { - role = "member" - } - tier, err := s.teamPlanTier(ctx, teamID) - if err != nil { - return nil, status.Error(codes.Internal, "team tier lookup failed") - } - limit := s.plans.TeamMemberLimit(tier) - - inv, err := models.InviteMember(ctx, s.db, teamID, req.GetEmail(), role, authUser, limit) - if err != nil { - return nil, teamMemberGRPCError(err) - } - - teamRow, err := models.GetTeamByID(ctx, s.db, teamID) - if err != nil { - slog.Warn("dashboardsvc.InviteMember.team_name_failed", "error", err, "team_id", teamID) - } - teamName := "" - if teamRow != nil && teamRow.Name.Valid { - teamName = teamRow.Name.String - } - if s.mail != nil { - base := strings.TrimRight(s.cfg.DashboardBaseURL, "/") - acceptURL := fmt.Sprintf("%s/settings?section=team&invite=%s", base, inv.ID.String()) - if err := s.mail.SendTeamInvite(ctx, inv.Email, teamName, acceptURL); err != nil { - slog.Warn("dashboardsvc.InviteMember.email_failed", "error", err, "invitation_id", inv.ID) - } - } - - return &dashboardv1.InviteMemberResponse{Invitation: invitationToProto(inv)}, nil -} - -// RemoveMember implements dashboard.v1.DashboardService/RemoveMember. -func (s *Server) RemoveMember(ctx context.Context, req *dashboardv1.RemoveMemberRequest) (*dashboardv1.RemoveMemberResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - if err := s.requireTeamOwner(ctx, teamID); err != nil { - return nil, err - } - target, err := uuid.Parse(req.GetTargetUserId()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid target_user_id") - } - if err := models.RemoveMember(ctx, s.db, teamID, target); err != nil { - return nil, teamMemberGRPCError(err) - } - return &dashboardv1.RemoveMemberResponse{Ok: true}, nil -} - -// ListInvitations implements dashboard.v1.DashboardService/ListInvitations. -func (s *Server) ListInvitations(ctx context.Context, req *dashboardv1.ListInvitationsRequest) (*dashboardv1.ListInvitationsResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - if err := s.requireTeamOwner(ctx, teamID); err != nil { - return nil, err - } - invs, err := models.ListInvitations(ctx, s.db, teamID) - if err != nil { - slog.Error("dashboardsvc.ListInvitations.query_failed", "error", err, "team_id", teamID) - return nil, status.Error(codes.Internal, "list invitations failed") - } - out := make([]*dashboardv1.TeamInvitation, 0, len(invs)) - for i := range invs { - out = append(out, invitationToProto(&invs[i])) - } - return &dashboardv1.ListInvitationsResponse{Invitations: out}, nil -} - -// RevokeInvitation implements dashboard.v1.DashboardService/RevokeInvitation. -func (s *Server) RevokeInvitation(ctx context.Context, req *dashboardv1.RevokeInvitationRequest) (*dashboardv1.RevokeInvitationResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - if err := s.requireTeamOwner(ctx, teamID); err != nil { - return nil, err - } - invID, err := uuid.Parse(req.GetInvitationId()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid invitation_id") - } - inv, err := models.GetInvitationByID(ctx, s.db, invID) - if err != nil { - return nil, teamMemberGRPCError(err) - } - if inv.TeamID != teamID { - return nil, status.Error(codes.PermissionDenied, "invitation does not belong to this team") - } - if err := models.RevokeInvitation(ctx, s.db, invID); err != nil { - return nil, teamMemberGRPCError(err) - } - return &dashboardv1.RevokeInvitationResponse{Ok: true}, nil -} - -// AcceptInvitation implements dashboard.v1.DashboardService/AcceptInvitation. -func (s *Server) AcceptInvitation(ctx context.Context, req *dashboardv1.AcceptInvitationRequest) (*dashboardv1.AcceptInvitationResponse, error) { - authUser, err := authUserID(ctx) - if err != nil { - return nil, err - } - invID, err := uuid.Parse(req.GetInvitationId()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, "invalid invitation_id") - } - inv, err := models.GetInvitationByID(ctx, s.db, invID) - if err != nil { - return nil, teamMemberGRPCError(err) - } - tier, err := s.teamPlanTier(ctx, inv.TeamID) - if err != nil { - return nil, status.Error(codes.Internal, "team tier lookup failed") - } - limit := s.plans.TeamMemberLimit(tier) - if err := models.AcceptInvitation(ctx, s.db, invID, authUser, limit); err != nil { - return nil, teamMemberGRPCError(err) - } - return &dashboardv1.AcceptInvitationResponse{Ok: true}, nil -} - -// LeaveTeam implements dashboard.v1.DashboardService/LeaveTeam. -func (s *Server) LeaveTeam(ctx context.Context, req *dashboardv1.LeaveTeamRequest) (*dashboardv1.LeaveTeamResponse, error) { - teamID, err := s.requireMatchingTeam(ctx, req.GetTeamId()) - if err != nil { - return nil, err - } - if err := s.requireMatchingUser(ctx, req.GetUserId()); err != nil { - return nil, err - } - authUser, err := authUserID(ctx) - if err != nil { - return nil, err - } - if err := models.LeaveTeam(ctx, s.db, teamID, authUser); err != nil { - return nil, teamMemberGRPCError(err) - } - return &dashboardv1.LeaveTeamResponse{Ok: true}, nil -} diff --git a/main.go b/main.go index a6da56f..af95ba8 100644 --- a/main.go +++ b/main.go @@ -3,25 +3,17 @@ package main import ( "context" "log/slog" - "net" "os" - "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" "instant.dev/internal/config" - "instant.dev/internal/dashboardsvc" "instant.dev/internal/db" "instant.dev/internal/email" "instant.dev/internal/middleware" "instant.dev/internal/plans" - compute "instant.dev/internal/providers/compute" - "instant.dev/internal/providers/compute/k8s" - "instant.dev/internal/providers/compute/noop" - storageprovider "instant.dev/internal/providers/storage" "instant.dev/internal/provisioner" "instant.dev/internal/router" "instant.dev/internal/telemetry" - dashboardv1 "instant.dev/proto/dashboard/v1" ) func main() { @@ -86,53 +78,6 @@ func main() { app := router.New(cfg, database, rdb, geoDbs, emailClient, planRegistry, provClient) - var storageProv *storageprovider.Provider - if cfg.MinioEndpoint != "" { - if sp, err := storageprovider.New(cfg.MinioEndpoint, cfg.MinioPublicEndpoint, cfg.MinioRootUser, cfg.MinioRootPassword, cfg.MinioBucketName); err != nil { - slog.Warn("dashboard_grpc: MinIO provider init failed", "error", err) - } else { - storageProv = sp - } - } - - var stackProv compute.StackProvider - if cfg.ComputeProvider == "k8s" { - ksp, err := k8s.NewStackProvider(cfg.KubeNamespaceApps, k8s.BuildContextConfig{ - Endpoint: cfg.MinioEndpoint, - AccessKey: cfg.MinioRootUser, - SecretKey: cfg.MinioRootPassword, - BucketName: "instant-build-contexts", - }) - if err != nil { - slog.Warn("dashboard_grpc.stack_k8s_unavailable", "error", err) - stackProv = noop.NewStack() - } else { - stackProv = ksp - } - } else { - stackProv = noop.NewStack() - } - - dashSvc := dashboardsvc.NewServer(database, rdb, cfg, planRegistry, provClient, storageProv, emailClient, stackProv) - grpcServer := grpc.NewServer( - grpc.StatsHandler(otelgrpc.NewServerHandler()), - grpc.UnaryInterceptor(dashboardsvc.AuthInterceptor(cfg.JWTSecret)), - ) - dashboardv1.RegisterDashboardServiceServer(grpcServer, dashSvc) - - grpcLis, err := net.Listen("tcp", ":50052") - if err != nil { - slog.Error("dashboard_grpc.listen_failed", "error", err) - os.Exit(1) - } - go func() { - slog.Info("dashboard_grpc.starting", "addr", grpcLis.Addr().String()) - if serveErr := grpcServer.Serve(grpcLis); serveErr != nil { - slog.Error("dashboard_grpc.serve_failed", "error", serveErr) - os.Exit(1) - } - }() - slog.Info("server.starting", "port", cfg.Port, "environment", cfg.Environment) if err := app.Listen(":" + cfg.Port); err != nil { slog.Error("server.fatal", "error", err)