Compare commits

..

3 Commits

Author SHA1 Message Date
allanice001
5fd96ec40f fix: types fixed
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-09 22:01:44 +00:00
allanice001
bc72df3c9a feat: add credentials management
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-09 21:46:31 +00:00
allanice001
56ea963b47 feat: add version info
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-08 13:08:19 +00:00
30 changed files with 2080 additions and 3715 deletions

View File

@@ -23,7 +23,7 @@ MODULE_PATH ?= $(GIT_HOST)/$(GIT_USER)/$(BIN)
# SDK / module settings (Go) # SDK / module settings (Go)
SDK_REPO ?= $(BIN)-sdk-go # repo name used for module path SDK_REPO ?= $(BIN)-sdk-go # repo name used for module path
SDK_OUTDIR ?= sdk/go # output directory (inside repo) SDK_OUTDIR ?= ../autoglue-sdk-go # output directory (inside repo)
SDK_PKG ?= ${BIN} # package name inside the SDK SDK_PKG ?= ${BIN} # package name inside the SDK
UI_SSG_ROUTES ?= /,/login,/docs,/pricing UI_SSG_ROUTES ?= /,/login,/docs,/pricing
@@ -70,7 +70,7 @@ export GO_POST_PROCESS_FILE := gofmt -w
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
# --- version metadata (ldflags) --- # --- version metadata (ldflags) ---
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always 2>/dev/null || echo "dev")
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "none") COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "none")
DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
BUILT_BY := $(shell whoami) BUILT_BY := $(shell whoami)
@@ -247,7 +247,6 @@ TS_PROPS := -p npmName=$(SDK_TS_NPM_NAME) -p npmVersion=$(SDK_TS_NPM_VER) $
# --- sdk generation (Go) --- # --- sdk generation (Go) ---
sdk-go: $(DOCS_JSON) validate-spec check-tags ## Generate Go SDK + tidy module sdk-go: $(DOCS_JSON) validate-spec check-tags ## Generate Go SDK + tidy module
@echo ">> Generating Go SDK (module $(GIT_HOST_CLEAN)/$(GIT_USER_CLEAN)/$(SDK_REPO_CLEAN), Go $(GO_VERSION))..." @echo ">> Generating Go SDK (module $(GIT_HOST_CLEAN)/$(GIT_USER_CLEAN)/$(SDK_REPO_CLEAN), Go $(GO_VERSION))..."
@rm -rf "$(SDK_OUTDIR_CLEAN)"; mkdir -p "$(SDK_OUTDIR_CLEAN)"
@$(call OGC_GENERATE,go,$(SDK_OUTDIR_CLEAN),--additional-properties=packageName=$(SDK_PKG_CLEAN) $(OAG_GIT_PROPS)) @$(call OGC_GENERATE,go,$(SDK_OUTDIR_CLEAN),--additional-properties=packageName=$(SDK_PKG_CLEAN) $(OAG_GIT_PROPS))
@cd "$(SDK_OUTDIR_CLEAN)"; \ @cd "$(SDK_OUTDIR_CLEAN)"; \
$(GOCMD) mod edit -go=$(GO_VERSION); \ $(GOCMD) mod edit -go=$(GO_VERSION); \

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

7
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0 github.com/go-chi/httprate v0.15.0
github.com/go-playground/validator/v10 v10.28.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
@@ -18,7 +19,7 @@ require (
github.com/swaggo/http-swagger/v2 v2.0.2 github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag/v2 v2.0.0-rc4 github.com/swaggo/swag/v2 v2.0.0-rc4
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.43.0
golang.org/x/oauth2 v0.32.0 golang.org/x/oauth2 v0.33.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7 gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
@@ -29,11 +30,14 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.3 // indirect github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
@@ -46,6 +50,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect

16
go.sum
View File

@@ -22,6 +22,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -43,6 +45,14 @@ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -89,6 +99,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -181,8 +193,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@@ -71,6 +71,7 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
v1.Get("/.well-known/jwks.json", handlers.JWKSHandler) v1.Get("/.well-known/jwks.json", handlers.JWKSHandler)
v1.Get("/healthz", handlers.HealthCheck) v1.Get("/healthz", handlers.HealthCheck)
v1.Get("/version", handlers.Version)
v1.Route("/auth", func(a chi.Router) { v1.Route("/auth", func(a chi.Router) {
a.Post("/{provider}/start", handlers.AuthStart(db)) a.Post("/{provider}/start", handlers.AuthStart(db))
@@ -126,6 +127,16 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
}) })
}) })
v1.Route("/credentials", func(c chi.Router) {
c.Use(authOrg)
c.Get("/", handlers.ListCredentials(db))
c.Post("/", handlers.CreateCredential(db))
c.Get("/{id}", handlers.GetCredential(db))
c.Patch("/{id}", handlers.UpdateCredential(db))
c.Delete("/{id}", handlers.DeleteCredential(db))
c.Post("/{id}/reveal", handlers.RevealCredential(db))
})
v1.Route("/ssh", func(s chi.Router) { v1.Route("/ssh", func(s chi.Router) {
s.Use(authOrg) s.Use(authOrg)
s.Get("/", handlers.ListPublicSshKeys(db)) s.Get("/", handlers.ListPublicSshKeys(db))

View File

@@ -40,6 +40,7 @@ func NewRuntime() *Runtime {
&models.Annotation{}, &models.Annotation{},
&models.NodePool{}, &models.NodePool{},
&models.Cluster{}, &models.Cluster{},
&models.Credential{},
) )
if err != nil { if err != nil {

View File

@@ -0,0 +1,186 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"gorm.io/gorm"
)
// ListClusters godoc
//
// @ID ListClusters
// @Summary List clusters (org scoped)
// @Description Returns clusters for the organization in X-Org-ID. Filter by `q` (name contains).
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param q query string false "Name contains (case-insensitive)"
// @Success 200 {array} dto.ClusterResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list clusters"
// @Router /clusters [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListClusters(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where(`name ILIKE ?`, "%"+needle+"%")
}
var rows []models.Cluster
if err := q.
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Labels").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
Preload("BastionServer").
Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ClusterResponse, 0, len(rows))
for _, row := range rows {
out = append(out, clusterToDTO(row))
}
}
}
// CreateCluster godoc
//
// @ID CreateCluster
// @Summary Create cluster (org scoped)
// @Description Creates a cluster. If `kubeconfig` is provided, it will be encrypted per-organization and stored securely (never returned).
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateClusterRequest true "payload"
// @Success 201 {object} dto.ClusterResponse
// @Failure 400 {string} string "invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /clusters [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
}
// -- Helpers
func clusterToDTO(c models.Cluster) dto.ClusterResponse {
var bastion *dto.ServerResponse
if c.BastionServer != nil {
b := serverToDTO(*c.BastionServer)
bastion = &b
}
nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
for _, np := range c.NodePools {
nps = append(nps, nodePoolToDTO(np))
}
return dto.ClusterResponse{
ID: c.ID,
Name: c.Name,
Provider: c.Provider,
Region: c.Region,
Status: c.Status,
CaptainDomain: c.CaptainDomain,
ClusterLoadBalancer: c.ClusterLoadBalancer,
RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey,
ControlLoadBalancer: c.ControlLoadBalancer,
NodePools: nps,
BastionServer: bastion,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
func nodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
labels := make([]dto.LabelResponse, 0, len(np.Labels))
for _, l := range np.Labels {
labels = append(labels, dto.LabelResponse{
Key: l.Key,
Value: l.Value,
})
}
annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
for _, a := range np.Annotations {
annotations = append(annotations, dto.AnnotationResponse{
Key: a.Key,
Value: a.Value,
})
}
taints := make([]dto.TaintResponse, 0, len(np.Taints))
for _, t := range np.Taints {
taints = append(taints, dto.TaintResponse{
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
servers := make([]dto.ServerResponse, 0, len(np.Servers))
for _, s := range np.Servers {
servers = append(servers, serverToDTO(s))
}
return dto.NodePoolResponse{
AuditFields: common.AuditFields{
ID: np.ID,
OrganizationID: np.OrganizationID,
CreatedAt: np.CreatedAt,
UpdatedAt: np.UpdatedAt,
},
Name: np.Name,
Role: dto.NodeRole(np.Role),
Labels: labels,
Annotations: annotations,
Taints: taints,
Servers: servers,
}
}
func serverToDTO(s models.Server) dto.ServerResponse {
return dto.ServerResponse{
ID: s.ID,
Hostname: s.Hostname,
PrivateIPAddress: s.PrivateIPAddress,
PublicIPAddress: s.PublicIPAddress,
Role: s.Role,
Status: s.Status,
SSHUser: s.SSHUser,
SshKeyID: s.SshKeyID,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
}
}

View File

@@ -0,0 +1,561 @@
package handlers
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ListCredentials godoc
// @ID ListCredentials
// @Summary List credentials (metadata only)
// @Description Returns credential metadata for the current org. Secrets are never returned.
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param provider query string false "Filter by provider (e.g., aws)"
// @Param kind query string false "Filter by kind (e.g., aws_access_key)"
// @Param scope_kind query string false "Filter by scope kind (provider/service/resource)"
// @Success 200 {array} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListCredentials(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if v := r.URL.Query().Get("provider"); v != "" {
q = q.Where("provider = ?", v)
}
if v := r.URL.Query().Get("kind"); v != "" {
q = q.Where("kind = ?", v)
}
if v := r.URL.Query().Get("scope_kind"); v != "" {
q = q.Where("scope_kind = ?", v)
}
var rows []models.Credential
if err := q.Order("updated_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
out := make([]dto.CredentialOut, 0, len(rows))
for i := range rows {
out = append(out, credOut(&rows[i]))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetCredential godoc
// @ID GetCredential
// @Summary Get credential by ID (metadata only)
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, credOut(&row))
}
}
// CreateCredential godoc
// @ID CreateCredential
// @Summary Create a credential (encrypts secret)
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param body body dto.CreateCredentialRequest true "Credential payload"
// @Success 201 {object} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var in dto.CreateCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.Validate.Struct(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
cred, err := SaveCredentialWithScope(
r.Context(), db, orgID,
in.Provider, in.Kind, in.SchemaVersion,
in.ScopeKind, in.ScopeVersion, json.RawMessage(in.Scope), json.RawMessage(in.Secret),
in.Name, in.AccountID, in.Region,
)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "save_failed", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, credOut(cred))
}
}
// UpdateCredential godoc
// @ID UpdateCredential
// @Summary Update credential metadata and/or rotate secret
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Param body body dto.UpdateCredentialRequest true "Fields to update"
// @Success 200 {object} dto.CredentialOut
// @Failure 403 {string} string "X-Org-ID required"
// @Failure 404 {string} string "not found"
// @Router /credentials/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
// Update metadata
if in.Name != nil {
row.Name = *in.Name
}
if in.AccountID != nil {
row.AccountID = *in.AccountID
}
if in.Region != nil {
row.Region = *in.Region
}
// Update scope (re-validate + fingerprint)
if in.ScopeKind != nil || in.Scope != nil || in.ScopeVersion != nil {
newKind := row.ScopeKind
if in.ScopeKind != nil {
newKind = *in.ScopeKind
}
newVersion := row.ScopeVersion
if in.ScopeVersion != nil {
newVersion = *in.ScopeVersion
}
if in.Scope == nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "scope must be provided when changing scope kind/version")
return
}
prScopes := dto.ScopeRegistry[row.Provider]
kScopes := prScopes[newKind]
sdef := kScopes[newVersion]
dst := sdef.New()
if err := json.Unmarshal(*in.Scope, dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_scope_json", err.Error())
return
}
if err := sdef.Validate(dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_scope", err.Error())
return
}
canonScope, err := canonicalJSON(dst)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
return
}
row.Scope = canonScope
row.ScopeKind = newKind
row.ScopeVersion = newVersion
row.ScopeFingerprint = sha256Hex(canonScope)
}
// Rotate secret
if in.Secret != nil {
// validate against current Provider/Kind/SchemaVersion
def := dto.CredentialRegistry[row.Provider][row.Kind][row.SchemaVersion]
dst := def.New()
if err := json.Unmarshal(*in.Secret, dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_secret_json", err.Error())
return
}
if err := def.Validate(dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_secret", err.Error())
return
}
canonSecret, err := canonicalJSON(dst)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
return
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "encrypt_error", err.Error())
return
}
row.EncryptedData = cipher
row.IV = iv
row.Tag = tag
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, credOut(&row))
}
}
// DeleteCredential godoc
// @ID DeleteCredential
// @Summary Delete credential
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 204
// @Failure 404 {string} string "not found"
// @Router /credentials/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Credential{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// RevealCredential godoc
// @ID RevealCredential
// @Summary Reveal decrypted secret (one-time read)
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} map[string]any
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /credentials/{id}/reveal [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func RevealCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
plain, err := utils.DecryptForOrg(orgID, row.EncryptedData, row.IV, row.Tag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "decrypt_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, plain)
}
}
// -- Helpers
func canonicalJSON(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m any
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
return marshalSorted(m)
}
func marshalSorted(v any) ([]byte, error) {
switch vv := v.(type) {
case map[string]any:
keys := make([]string, 0, len(vv))
for k := range vv {
keys = append(keys, k)
}
sort.Strings(keys)
buf := bytes.NewBufferString("{")
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, _ := json.Marshal(k)
buf.Write(kb)
buf.WriteByte(':')
b, err := marshalSorted(vv[k])
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte('}')
return buf.Bytes(), nil
case []any:
buf := bytes.NewBufferString("[")
for i, e := range vv {
if i > 0 {
buf.WriteByte(',')
}
b, err := marshalSorted(e)
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte(']')
return buf.Bytes(), nil
default:
return json.Marshal(v)
}
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// SaveCredentialWithScope validates secret+scope, encrypts, fingerprints, and stores.
func SaveCredentialWithScope(
ctx context.Context,
db *gorm.DB,
orgID uuid.UUID,
provider, kind string,
schemaVersion int,
scopeKind string,
scopeVersion int,
rawScope json.RawMessage,
rawSecret json.RawMessage,
name, accountID, region string,
) (*models.Credential, error) {
// 1) secret shape
pv, ok := dto.CredentialRegistry[provider]
if !ok {
return nil, fmt.Errorf("unknown provider %q", provider)
}
kv, ok := pv[kind]
if !ok {
return nil, fmt.Errorf("unknown kind %q for provider %q", kind, provider)
}
def, ok := kv[schemaVersion]
if !ok {
return nil, fmt.Errorf("unsupported schema version %d for %s/%s", schemaVersion, provider, kind)
}
secretDst := def.New()
if err := json.Unmarshal(rawSecret, secretDst); err != nil {
return nil, fmt.Errorf("payload is not valid JSON for %s/%s: %w", provider, kind, err)
}
if err := def.Validate(secretDst); err != nil {
return nil, fmt.Errorf("invalid %s/%s: %w", provider, kind, err)
}
// 2) scope shape
prScopes, ok := dto.ScopeRegistry[provider]
if !ok {
return nil, fmt.Errorf("no scopes registered for provider %q", provider)
}
kScopes, ok := prScopes[scopeKind]
if !ok {
return nil, fmt.Errorf("invalid scope_kind %q for provider %q", scopeKind, provider)
}
sdef, ok := kScopes[scopeVersion]
if !ok {
return nil, fmt.Errorf("unsupported scope version %d for %s/%s", scopeVersion, provider, scopeKind)
}
scopeDst := sdef.New()
if err := json.Unmarshal(rawScope, scopeDst); err != nil {
return nil, fmt.Errorf("invalid scope JSON: %w", err)
}
if err := sdef.Validate(scopeDst); err != nil {
return nil, fmt.Errorf("invalid scope: %w", err)
}
// 3) canonicalize scope (also what we persist in plaintext)
canonScope, err := canonicalJSON(scopeDst)
if err != nil {
return nil, err
}
fp := sha256Hex(canonScope) // or HMAC if you have a server-side key
// 4) canonicalize + encrypt secret
canonSecret, err := canonicalJSON(secretDst)
if err != nil {
return nil, err
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
cred := &models.Credential{
OrganizationID: orgID,
Provider: provider,
Kind: kind,
SchemaVersion: schemaVersion,
Name: name,
ScopeKind: scopeKind,
Scope: datatypes.JSON(canonScope),
ScopeVersion: scopeVersion,
AccountID: accountID,
Region: region,
ScopeFingerprint: fp,
EncryptedData: cipher,
IV: iv,
Tag: tag,
}
if err := db.WithContext(ctx).Create(cred).Error; err != nil {
return nil, err
}
return cred, nil
}
// credOut converts model → response DTO
func credOut(c *models.Credential) dto.CredentialOut {
return dto.CredentialOut{
ID: c.ID.String(),
Provider: c.Provider,
Kind: c.Kind,
SchemaVersion: c.SchemaVersion,
Name: c.Name,
ScopeKind: c.ScopeKind,
ScopeVersion: c.ScopeVersion,
Scope: dto.RawJSON(c.Scope),
AccountID: c.AccountID,
Region: c.Region,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
}
}

View File

@@ -0,0 +1,34 @@
package dto
import (
"time"
"github.com/google/uuid"
)
type ClusterResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Region string `json:"region"`
Status string `json:"status"`
CaptainDomain string `json:"captain_domain"`
ClusterLoadBalancer string `json:"cluster_load_balancer"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
ControlLoadBalancer string `json:"control_load_balancer"`
NodePools []NodePoolResponse `json:"node_pools,omitempty"`
BastionServer *ServerResponse `json:"bastion_server,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateClusterRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
Region string `json:"region"`
Status string `json:"status"`
CaptainDomain string `json:"captain_domain"`
ClusterLoadBalancer *string `json:"cluster_load_balancer"`
ControlLoadBalancer *string `json:"control_load_balancer"`
}

View File

@@ -0,0 +1,138 @@
package dto
import (
"encoding/json"
"github.com/go-playground/validator/v10"
)
// RawJSON is a swagger-friendly wrapper for json.RawMessage.
type RawJSON json.RawMessage
var Validate = validator.New()
func init() {
_ = Validate.RegisterValidation("awsarn", func(fl validator.FieldLevel) bool {
v := fl.Field().String()
return len(v) > 10 && len(v) < 2048 && len(v) >= 4 && v[:4] == "arn:"
})
}
/*** Shapes for secrets ***/
type AWSCredential struct {
AccessKeyID string `json:"access_key_id" validate:"required,alphanum,len=20"`
SecretAccessKey string `json:"secret_access_key" validate:"required"`
Region string `json:"region" validate:"omitempty"`
}
type BasicAuth struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
type APIToken struct {
Token string `json:"token" validate:"required"`
}
type OAuth2Credential struct {
ClientID string `json:"client_id" validate:"required"`
ClientSecret string `json:"client_secret" validate:"required"`
RefreshToken string `json:"refresh_token" validate:"required"`
}
/*** Shapes for scopes ***/
type AWSProviderScope struct{}
type AWSServiceScope struct {
Service string `json:"service" validate:"required,oneof=route53 s3 ec2 iam rds dynamodb"`
}
type AWSResourceScope struct {
ARN string `json:"arn" validate:"required,awsarn"`
}
/*** Registries ***/
type ProviderDef struct {
New func() any
Validate func(any) error
}
type ScopeDef struct {
New func() any
Validate func(any) error
Specificity int // 0=provider, 1=service, 2=resource
}
// Secret shapes per provider/kind/version
var CredentialRegistry = map[string]map[string]map[int]ProviderDef{
"aws": {
"aws_access_key": {
1: {New: func() any { return &AWSCredential{} }, Validate: func(x any) error { return Validate.Struct(x) }},
},
},
"cloudflare": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"hetzner": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"digitalocean": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"generic": {
"basic_auth": {1: {New: func() any { return &BasicAuth{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
"oauth2": {1: {New: func() any { return &OAuth2Credential{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
},
}
// Scope shapes per provider/scopeKind/version
var ScopeRegistry = map[string]map[string]map[int]ScopeDef{
"aws": {
"provider": {1: {New: func() any { return &AWSProviderScope{} }, Validate: func(any) error { return nil }, Specificity: 0}},
"service": {1: {New: func() any { return &AWSServiceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 1}},
"resource": {1: {New: func() any { return &AWSResourceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 2}},
},
}
/*** API DTOs used by swagger ***/
// CreateCredentialRequest represents the POST /credentials payload
type CreateCredentialRequest struct {
Provider string `json:"provider" validate:"required,oneof=aws cloudflare hetzner digitalocean generic"`
Kind string `json:"kind" validate:"required"` // aws_access_key, api_token, basic_auth, oauth2
SchemaVersion int `json:"schema_version" validate:"required,gte=1"` // secret schema version
Name string `json:"name" validate:"omitempty,max=100"` // human label
ScopeKind string `json:"scope_kind" validate:"required,oneof=provider service resource"`
ScopeVersion int `json:"scope_version" validate:"required,gte=1"` // scope schema version
Scope RawJSON `json:"scope" validate:"required" swaggertype:"object"` // {"service":"route53"} or {"arn":"..."}
AccountID string `json:"account_id,omitempty" validate:"omitempty,max=32"`
Region string `json:"region,omitempty" validate:"omitempty,max=32"`
Secret RawJSON `json:"secret" validate:"required" swaggertype:"object"` // encrypted later
}
// UpdateCredentialRequest represents PATCH /credentials/{id}
type UpdateCredentialRequest struct {
Name *string `json:"name,omitempty"`
AccountID *string `json:"account_id,omitempty"`
Region *string `json:"region,omitempty"`
ScopeKind *string `json:"scope_kind,omitempty"`
ScopeVersion *int `json:"scope_version,omitempty"`
Scope *RawJSON `json:"scope,omitempty" swaggertype:"object"`
Secret *RawJSON `json:"secret,omitempty" swaggertype:"object"` // set if rotating
}
// CredentialOut is what we return (no secrets)
type CredentialOut struct {
ID string `json:"id"`
Provider string `json:"provider"`
Kind string `json:"kind"`
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
ScopeKind string `json:"scope_kind"`
ScopeVersion int `json:"scope_version"`
Scope RawJSON `json:"scope" swaggertype:"object"`
AccountID string `json:"account_id,omitempty"`
Region string `json:"region,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

View File

@@ -57,6 +57,6 @@ type PageJob struct {
type EnqueueRequest struct { type EnqueueRequest struct {
Queue string `json:"queue" example:"default"` Queue string `json:"queue" example:"default"`
Type string `json:"type" example:"email.send"` Type string `json:"type" example:"email.send"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload" swaggertype:"object"`
RunAt *time.Time `json:"run_at" example:"2025-11-05T08:00:00Z"` RunAt *time.Time `json:"run_at" example:"2025-11-05T08:00:00Z"`
} }

View File

@@ -0,0 +1,65 @@
package handlers
import (
"net/http"
"runtime"
"runtime/debug"
"strconv"
"github.com/glueops/autoglue/internal/utils"
"github.com/glueops/autoglue/internal/version"
)
type VersionResponse struct {
Version string `json:"version" example:"1.4.2"`
Commit string `json:"commit" example:"a1b2c3d"`
Built string `json:"built" example:"2025-11-08T12:34:56Z"`
BuiltBy string `json:"builtBy" example:"ci"`
Go string `json:"go" example:"go1.23.3"`
GOOS string `json:"goOS" example:"linux"`
GOARCH string `json:"goArch" example:"amd64"`
VCS string `json:"vcs,omitempty" example:"git"`
Revision string `json:"revision,omitempty" example:"a1b2c3d4e5f6abcdef"`
CommitTime string `json:"commitTime,omitempty" example:"2025-11-08T12:31:00Z"`
Modified *bool `json:"modified,omitempty" example:"false"`
}
// Version godoc
//
// @Summary Service version information
// @Description Returns build/runtime metadata for the running service.
// @Tags Meta
// @ID Version // operationId
// @Accept json
// @Produce json
// @Success 200 {object} VersionResponse
// @Router /version [get]
func Version(w http.ResponseWriter, r *http.Request) {
resp := VersionResponse{
Version: version.Version,
Commit: version.Commit,
Built: version.Date,
BuiltBy: version.BuiltBy,
Go: runtime.Version(),
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
}
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs":
resp.VCS = s.Value
case "vcs.revision":
resp.Revision = s.Value
case "vcs.time":
resp.CommitTime = s.Value
case "vcs.modified":
if b, err := strconv.ParseBool(s.Value); err == nil {
resp.Modified = &b
}
}
}
}
utils.WriteJSON(w, http.StatusOK, resp)
}

View File

@@ -16,6 +16,7 @@ type Cluster struct {
Status string `json:"status"` Status string `json:"status"`
CaptainDomain string `gorm:"not null" json:"captain_domain"` CaptainDomain string `gorm:"not null" json:"captain_domain"`
ClusterLoadBalancer string `json:"cluster_load_balancer"` ClusterLoadBalancer string `json:"cluster_load_balancer"`
ControlLoadBalancer string `json:"control_load_balancer"`
RandomToken string `json:"random_token"` RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"` CertificateKey string `json:"certificate_key"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"` EncryptedKubeconfig string `gorm:"type:text" json:"-"`

View File

@@ -0,0 +1,29 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Credential struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Provider string `gorm:"type:varchar(50);not null;index"`
Kind string `gorm:"type:varchar(50);not null;index"` // "aws_access_key", "api_token", "basic_auth", ...
SchemaVersion int `gorm:"not null;default:1"`
Name string `gorm:"type:varchar(100);not null;default:''"` // human label, lets you have multiple for same service
ScopeKind string `gorm:"type:varchar(20);not null"` // "provider" | "service" | "resource"
Scope datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"` // e.g. {"service":"route53"} or {"arn":"arn:aws:s3:::my-bucket"}
ScopeVersion int `gorm:"not null;default:1"`
AccountID string `gorm:"type:varchar(32)"` // AWS account ID if applicable
Region string `gorm:"type:varchar(32)"` // default region (non-secret)
ScopeFingerprint string `gorm:"type:char(64);not null;index"`
EncryptedData string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
}

20
internal/models/dns.go Normal file
View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Dns struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
ClusterID *uuid.UUID `gorm:"type:uuid" json:"cluster_id,omitempty"`
Cluster *Cluster `gorm:"foreignKey:ClusterID" json:"cluster,omitempty"`
Type string `gorm:"not null" json:"type,omitempty"`
Name string `gorm:"not null" json:"name,omitempty"`
Content string `gorm:"not null" json:"content,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

@@ -12,7 +12,7 @@ type NodePool struct {
Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"` Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"` Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"` Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
//Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"` Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
//Topology string `gorm:"not null,default:'stacked'" json:"topology,omitempty"` // stacked or external //Topology string `gorm:"not null,default:'stacked'" json:"topology,omitempty"` // stacked or external
Role string `gorm:"not null,default:'worker'" json:"role,omitempty"` // master, worker, or etcd (etcd only if topology = external Role string `gorm:"not null,default:'worker'" json:"role,omitempty"` // master, worker, or etcd (etcd only if topology = external
} }

View File

@@ -56,7 +56,7 @@
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.12" "zod": "^4.1.12"
@@ -69,8 +69,6 @@
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "5.1.0", "@vitejs/plugin-react": "5.1.0",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.4.24", "eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0", "globals": "16.5.0",

View File

@@ -4,6 +4,7 @@ import { Route, Routes } from "react-router-dom"
import { ProtectedRoute } from "@/components/protected-route.tsx" import { ProtectedRoute } from "@/components/protected-route.tsx"
import { AnnotationPage } from "@/pages/annotations/annotation-page.tsx" import { AnnotationPage } from "@/pages/annotations/annotation-page.tsx"
import { Login } from "@/pages/auth/login.tsx" import { Login } from "@/pages/auth/login.tsx"
import { CredentialPage } from "@/pages/credentials/credential-page.tsx"
import { JobsPage } from "@/pages/jobs/jobs-page.tsx" import { JobsPage } from "@/pages/jobs/jobs-page.tsx"
import { LabelsPage } from "@/pages/labels/labels-page.tsx" import { LabelsPage } from "@/pages/labels/labels-page.tsx"
import { MePage } from "@/pages/me/me-page.tsx" import { MePage } from "@/pages/me/me-page.tsx"
@@ -33,6 +34,7 @@ export default function App() {
<Route path="/labels" element={<LabelsPage />} /> <Route path="/labels" element={<LabelsPage />} />
<Route path="/annotations" element={<AnnotationPage />} /> <Route path="/annotations" element={<AnnotationPage />} />
<Route path="/node-pools" element={<NodePoolsPage />} /> <Route path="/node-pools" element={<NodePoolsPage />} />
<Route path="/credentials" element={<CredentialPage />} />
<Route path="/admin/jobs" element={<JobsPage />} /> <Route path="/admin/jobs" element={<JobsPage />} />
</Route> </Route>

View File

@@ -18,7 +18,12 @@ export const archerAdminApi = {
return await archerAdmin.adminListArcherJobs(params) return await archerAdmin.adminListArcherJobs(params)
}) })
}, },
enqueue: (body: { queue: string; type: string; payload?: unknown; run_at?: string }) => { enqueue: (body: {
queue: string
type: string
payload?: object | undefined
run_at?: string
}) => {
return withRefresh(async () => { return withRefresh(async () => {
return await archerAdmin.adminEnqueueArcherJob({ body }) return await archerAdmin.adminEnqueueArcherJob({ body })
}) })

32
ui/src/api/credentials.ts Normal file
View File

@@ -0,0 +1,32 @@
import { withRefresh } from "@/api/with-refresh.ts"
import type { DtoCreateCredentialRequest, DtoUpdateCredentialRequest } from "@/sdk"
import { makeCredentialsApi } from "@/sdkClient.ts"
const credentials = makeCredentialsApi()
export const credentialsApi = {
listCredentials: () =>
withRefresh(async () => {
return await credentials.listCredentials()
}),
createCredential: async (body: DtoCreateCredentialRequest) =>
withRefresh(async () => {
return await credentials.createCredential({ body })
}),
getCredential: async (id: string) =>
withRefresh(async () => {
return await credentials.getCredential({ id })
}),
deleteCredential: async (id: string) =>
withRefresh(async () => {
await credentials.deleteCredential({ id })
}),
updateCredential: async (id: string, body: DtoUpdateCredentialRequest) =>
withRefresh(async () => {
return await credentials.updateCredential({ id, body })
}),
revealCredential: async (id: string) =>
withRefresh(async () => {
return await credentials.revealCredential({ id })
}),
}

15
ui/src/api/footer.ts Normal file
View File

@@ -0,0 +1,15 @@
export const metaApi = {
footer: async () => {
const res = await fetch("/api/v1/version", { cache: "no-store" })
if (!res.ok) throw new Error("failed to fetch version")
return (await res.json()) as {
built: string
builtBy: string
commit: string
go: string
goArch: string
goOS: string
version: string
}
},
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { meApi } from "@/api/me.ts" import { meApi } from "@/api/me.ts"
import { orgStore } from "@/auth/org.ts" import { orgStore } from "@/auth/org.ts"
import { authStore } from "@/auth/store.ts" import { Footer } from "@/layouts/footer.tsx"
import { adminNav, mainNav, orgNav, userNav } from "@/layouts/nav-config.ts" import { adminNav, mainNav, orgNav, userNav } from "@/layouts/nav-config.ts"
import { OrgSwitcher } from "@/layouts/org-switcher.tsx" import { OrgSwitcher } from "@/layouts/org-switcher.tsx"
import { Topbar } from "@/layouts/topbar.tsx" import { Topbar } from "@/layouts/topbar.tsx"
@@ -171,11 +171,12 @@ export const AppShell = () => {
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<SidebarInset className="min-h-screen"> <SidebarInset className="flex min-h-screen flex-col">
<Topbar /> <Topbar />
<main className="p-4"> <main className="p-4">
<Outlet /> <Outlet />
</main> </main>
<Footer />
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
) )

135
ui/src/layouts/footer.tsx Normal file
View File

@@ -0,0 +1,135 @@
import { memo, useMemo } from "react"
import { metaApi } from "@/api/footer"
import { useQuery } from "@tanstack/react-query"
import { Clipboard, ExternalLink, GitCommit, Info } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
type VersionInfo = {
built: string // ISO string or "unknown"
builtBy: string
commit: string
go: string
goArch: string
goOS: string
version: string
}
function shortCommit(c?: string) {
return c && c !== "none" ? c.slice(0, 7) : "none"
}
function formatBuilt(built: string) {
if (!built || built === "unknown") return "unknown"
const d = new Date(built)
return isNaN(+d) ? built : d.toLocaleString()
}
function asClipboardText(v?: VersionInfo) {
if (!v) return ""
return `v${v.version} (${shortCommit(v.commit)}) • built ${v.built}${v.go} ${v.goOS}/${v.goArch}`
}
export const Footer = memo(function Footer({ className }: { className?: string }) {
const footerQ = useQuery({
queryKey: ["footer"],
queryFn: () => metaApi.footer() as Promise<VersionInfo>,
staleTime: 60_000,
refetchOnWindowFocus: false,
})
const data = footerQ.data
const copyText = useMemo(() => asClipboardText(data), [data])
return (
<footer className="bg-background text-muted-foreground w-full border-t px-3 py-2 text-xs sm:text-sm">
<div className="mx-auto flex max-w-screen-2xl items-center justify-between">
{/* Left: brand / copyright */}
<div className="flex items-center gap-2 text-xs sm:text-sm">
<span>© {new Date().getFullYear()} GlueOps</span>
<Separator orientation="vertical" className="hidden h-4 sm:block" />
<span className="hidden sm:block">All systems nominal.</span>
</div>
{/* Right: version/meta */}
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
{footerQ.isLoading ? (
<span className="animate-pulse">loading version</span>
) : footerQ.isError ? (
<span className="text-destructive">version unavailable</span>
) : data ? (
<TooltipProvider>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="font-mono">
{data.version}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1">
<GitCommit className="h-3.5 w-3.5" />
<span className="font-mono">{shortCommit(data.commit)}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
<div className="font-mono text-xs">{data.commit}</div>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-4" />
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1">
<Info className="h-3.5 w-3.5" />
<span>{data.go}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
<div className="font-mono text-xs">
{data.goOS}/{data.goArch}
</div>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="hidden h-4 sm:block" />
<span className="hidden sm:inline">
built <span className="font-mono">{formatBuilt(data.built)}</span>
</span>
<Separator orientation="vertical" className="hidden h-4 sm:block" />
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
navigator.clipboard?.writeText(copyText).catch(() => {})
}}
title="Copy version details"
>
<Clipboard className="h-4 w-4" />
</Button>
<a
href="/api/v1/version"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs underline-offset-4 hover:underline"
title="Open raw version JSON"
>
JSON <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</TooltipProvider>
) : null}
</div>
</div>
</footer>
)
})

View File

@@ -5,6 +5,7 @@ import {
ComponentIcon, ComponentIcon,
FileKey2Icon, FileKey2Icon,
KeyRound, KeyRound,
LockKeyholeIcon,
ServerIcon, ServerIcon,
SprayCanIcon, SprayCanIcon,
TagsIcon, TagsIcon,
@@ -28,6 +29,7 @@ export const mainNav: NavItem[] = [
{ to: "/taints", label: "Taints", icon: SprayCanIcon }, { to: "/taints", label: "Taints", icon: SprayCanIcon },
{ to: "/servers", label: "Servers", icon: ServerIcon }, { to: "/servers", label: "Servers", icon: ServerIcon },
{ to: "/ssh", label: "SSH Keys", icon: FileKey2Icon }, { to: "/ssh", label: "SSH Keys", icon: FileKey2Icon },
{ to: "/credentials", label: "Credentials", icon: LockKeyholeIcon },
] ]
export const orgNav: NavItem[] = [ export const orgNav: NavItem[] = [

View File

@@ -0,0 +1,790 @@
import { useMemo, useState } from "react"
import { credentialsApi } from "@/api/credentials"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Eye, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
// ---------- Schemas ----------
const jsonTransform = z
.string()
.min(2, "JSON required")
.refine((v) => {
try {
JSON.parse(v)
return true
} catch {
return false
}
}, "Invalid JSON")
.transform((v) => JSON.parse(v))
const createCredentialSchema = z.object({
provider: z.enum(["aws", "cloudflare", "hetzner", "digitalocean", "generic"]),
kind: z.enum(["aws_access_key", "api_token", "basic_auth", "oauth2"]),
schema_version: z.number().default(1),
name: z.string().min(1, "Name is required").max(100),
scope_kind: z.enum(["provider", "service", "resource"]),
scope_version: z.number().default(1),
scope: jsonTransform,
account_id: z
.string()
.optional()
.or(z.literal(""))
.transform((v) => (v ? v : undefined)),
region: z
.string()
.optional()
.or(z.literal(""))
.transform((v) => (v ? v : undefined)),
// Secrets are always JSON — makes rotate easy on update form too
secret: jsonTransform,
})
type CreateCredentialInput = z.input<typeof createCredentialSchema>
type CreateCredentialValues = z.infer<typeof createCredentialSchema>
const updateCredentialSchema = createCredentialSchema.partial().extend({
// allow rotating secret independently
secret: jsonTransform.optional(),
name: z.string().min(1, "Name is required").max(100).optional(),
})
// ---------- Helpers ----------
function pretty(obj: unknown) {
try {
return JSON.stringify(obj, null, 2)
} catch {
return ""
}
}
function toFormDefaults<T extends Record<string, any>>(initial: Partial<T>) {
return {
schema_version: 1,
scope_version: 1,
...initial,
} as any
}
// ---------- Page ----------
export const CredentialPage = () => {
const [filter, setFilter] = useState<string>("")
const [createOpen, setCreateOpen] = useState<boolean>(false)
const [editOpen, setEditOpen] = useState<boolean>(false)
const [revealOpen, setRevealOpen] = useState<boolean>(false)
const [revealJson, setRevealJson] = useState<object | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const qc = useQueryClient()
// List
const credentialQ = useQuery({
queryKey: ["credentials"],
queryFn: () => credentialsApi.listCredentials(),
})
// Create
const createMutation = useMutation({
mutationFn: (body: CreateCredentialValues) =>
credentialsApi.createCredential({
provider: body.provider,
kind: body.kind,
schema_version: body.schema_version ?? 1,
name: body.name,
scope_kind: body.scope_kind,
scope_version: body.scope_version ?? 1,
scope: body.scope,
account_id: body.account_id,
region: body.region,
secret: body.secret,
}),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["credentials"] })
toast.success("Credential created")
setCreateOpen(false)
createForm.reset(createDefaults) // clear JSON textareas etc
},
onError: (err: any) => {
toast.error("Failed to create credential", {
description: err?.message ?? "Unknown error",
})
},
})
// Update
const updateMutation = useMutation({
mutationFn: (payload: { id: string; body: z.infer<typeof updateCredentialSchema> }) =>
credentialsApi.updateCredential(payload.id, payload.body),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["credentials"] })
toast.success("Credential updated")
setEditOpen(false)
setEditingId(null)
},
onError: (err: any) => {
toast.error("Failed to update credential", {
description: err?.message ?? "Unknown error",
})
},
})
// Delete
const deleteMutation = useMutation({
mutationFn: (id: string) => credentialsApi.deleteCredential(id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["credentials"] })
toast.success("Credential deleted")
},
onError: (err: any) => {
toast.error("Failed to delete credential", {
description: err?.message ?? "Unknown error",
})
},
})
// Reveal (one-time read)
const revealMutation = useMutation({
mutationFn: (id: string) => credentialsApi.revealCredential(id),
onSuccess: (data) => {
setRevealJson(data)
setRevealOpen(true)
},
onError: (err: any) => {
toast.error("Failed to reveal secret", {
description: err?.message ?? "Unknown error",
})
},
})
// ---------- Forms ----------
const createDefaults: CreateCredentialInput = toFormDefaults<CreateCredentialInput>({
provider: "aws",
kind: "aws_access_key",
schema_version: 1,
scope_kind: "provider",
scope_version: 1,
name: "",
// IMPORTANT: default valid JSON strings so zod.transform succeeds
scope: "{}" as any,
secret: "{}" as any,
account_id: "",
region: "",
})
const createForm = useForm<CreateCredentialInput>({
resolver: zodResolver(createCredentialSchema),
defaultValues: createDefaults,
mode: "onBlur",
})
const editForm = useForm<z.input<typeof updateCredentialSchema>>({
resolver: zodResolver(updateCredentialSchema),
defaultValues: {
// populated on open
},
mode: "onBlur",
})
function openEdit(row: any) {
setEditingId(row.id)
editForm.reset({
provider: row.provider,
kind: row.kind,
schema_version: row.schema_version ?? 1,
name: row.name,
scope_kind: row.scope_kind,
scope_version: row.scope_version ?? 1,
account_id: row.account_id ?? "",
region: row.region ?? "",
// show JSON in textareas
scope: pretty(row.scope ?? {}),
// secret is optional on update; leave empty to avoid rotate
secret: undefined,
} as any)
setEditOpen(true)
}
const filtered = useMemo(() => {
const items = credentialQ.data ?? []
if (!filter.trim()) return items
const f = filter.toLowerCase()
return items.filter((c: any) =>
[
c.name,
c.provider,
c.kind,
c.scope_kind,
c.account_id,
c.region,
JSON.stringify(c.scope ?? {}),
]
.filter(Boolean)
.map((x: any) => String(x).toLowerCase())
.some((s: string) => s.includes(f))
)
}, [credentialQ.data, filter])
// ---------- UI ----------
if (credentialQ.isLoading)
return (
<div className="flex items-center gap-2 p-6">
<Loader2 className="h-4 w-4 animate-spin" /> Loading credentials
</div>
)
if (credentialQ.error)
return (
<div className="p-6 text-red-500">
Error loading credentials.
<pre className="mt-2 text-xs opacity-80">{JSON.stringify(credentialQ.error, null, 2)}</pre>
</div>
)
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-1 text-2xl font-bold">Credentials</h1>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search by name, provider, kind, scope…"
className="w-64 pl-8"
/>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Credential
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Create Credential</DialogTitle>
</DialogHeader>
<Form {...createForm}>
<form
onSubmit={createForm.handleSubmit((values) =>
createMutation.mutate(values as CreateCredentialValues)
)}
className="space-y-4 pt-2"
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={createForm.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="aws">AWS</SelectItem>
<SelectItem value="cloudflare">Cloudflare</SelectItem>
<SelectItem value="hetzner">Hetzner</SelectItem>
<SelectItem value="digitalocean">DigitalOcean</SelectItem>
<SelectItem value="generic">Generic</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="kind"
render={({ field }) => (
<FormItem>
<FormLabel>Kind</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="aws_access_key">AWS Access Key</SelectItem>
<SelectItem value="api_token">API Token</SelectItem>
<SelectItem value="basic_auth">Basic Auth</SelectItem>
<SelectItem value="oauth2">OAuth2</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="scope_kind"
render={({ field }) => (
<FormItem>
<FormLabel>Scope Kind</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="provider">Provider</SelectItem>
<SelectItem value="service">Service</SelectItem>
<SelectItem value="resource">Resource</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<Input {...field} placeholder="My AWS Key" />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="account_id"
render={({ field }) => (
<FormItem>
<FormLabel>Account ID (optional)</FormLabel>
<Input {...field} placeholder="e.g. 123456789012" />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="region"
render={({ field }) => (
<FormItem>
<FormLabel>Region (optional)</FormLabel>
<Input {...field} placeholder="e.g. us-east-1" />
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={createForm.control}
name="scope"
render={({ field }) => (
<FormItem>
<FormLabel>Scope (JSON)</FormLabel>
<Textarea
{...field}
rows={3}
placeholder='e.g. {"service":"s3"} or {"arn":"..."}'
className="font-mono"
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret (JSON)</FormLabel>
<Textarea
{...field}
rows={6}
placeholder='{"access_key_id":"...","secret_access_key":"..."}'
className="font-mono"
/>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button variant="outline" type="button" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-xl border">
<table className="min-w-full text-sm">
<thead className="bg-muted/40 text-xs tracking-wide uppercase">
<tr>
<th className="w-[28%] px-4 py-2 text-left">Name</th>
<th className="px-4 py-2 text-left">Provider</th>
<th className="px-4 py-2 text-left">Kind</th>
<th className="px-4 py-2 text-left">Scope Kind</th>
<th className="px-4 py-2 text-left">Account</th>
<th className="px-4 py-2 text-left">Region</th>
<th className="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((row: any) => (
<tr key={row.id} className="border-t">
<td className="px-4 py-2 font-medium">{row.name}</td>
<td className="px-4 py-2">{row.provider}</td>
<td className="px-4 py-2">{row.kind}</td>
<td className="px-4 py-2">{row.scope_kind}</td>
<td className="px-4 py-2">{row.account_id ?? "—"}</td>
<td className="px-4 py-2">{row.region ?? "—"}</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-2">
<Button
size="icon"
variant="ghost"
title="Reveal secret (one-time read)"
onClick={() => revealMutation.mutate(row.id)}
>
<Eye className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="Edit" onClick={() => openEdit(row)}>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="ghost" title="Delete">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {row.name}?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove the credential metadata. Secrets are not
recoverable from the service.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate(row.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(row)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => revealMutation.mutate(row.id)}>
Reveal secret
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteMutation.mutate(row.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={7} className="text-muted-foreground px-4 py-10 text-center">
No credentials match your search.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Edit Credential</DialogTitle>
</DialogHeader>
<Form {...editForm}>
<form
onSubmit={editForm.handleSubmit((values) => {
if (!editingId) return
// Convert stringified JSON fields to objects via schema
const parsed = updateCredentialSchema.safeParse(values)
if (!parsed.success) {
toast.error("Please fix validation errors")
return
}
updateMutation.mutate({ id: editingId, body: parsed.data })
})}
className="space-y-4 pt-2"
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={editForm.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="aws">AWS</SelectItem>
<SelectItem value="cloudflare">Cloudflare</SelectItem>
<SelectItem value="hetzner">Hetzner</SelectItem>
<SelectItem value="digitalocean">DigitalOcean</SelectItem>
<SelectItem value="generic">Generic</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="kind"
render={({ field }) => (
<FormItem>
<FormLabel>Kind</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="aws_access_key">AWS Access Key</SelectItem>
<SelectItem value="api_token">API Token</SelectItem>
<SelectItem value="basic_auth">Basic Auth</SelectItem>
<SelectItem value="oauth2">OAuth2</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="scope_kind"
render={({ field }) => (
<FormItem>
<FormLabel>Scope Kind</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="provider">Provider</SelectItem>
<SelectItem value="service">Service</SelectItem>
<SelectItem value="resource">Resource</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<Input {...field} />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="account_id"
render={({ field }) => (
<FormItem>
<FormLabel>Account ID</FormLabel>
<Input {...field} placeholder="optional" />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="region"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<Input {...field} placeholder="optional" />
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editForm.control}
name="scope"
render={({ field }) => (
<FormItem>
<FormLabel>Scope (JSON)</FormLabel>
<Textarea {...field} rows={3} className="font-mono" />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Rotate Secret (JSON, optional)</FormLabel>
<Textarea
{...field}
rows={6}
className="font-mono"
placeholder="Leave empty to keep existing secret"
/>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button variant="outline" type="button" onClick={() => setEditOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Reveal modal */}
<Dialog open={revealOpen} onOpenChange={setRevealOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Decrypted Secret</DialogTitle>
</DialogHeader>
<div className="bg-muted/40 rounded-lg border p-3">
<pre className="max-h-[50vh] overflow-auto text-xs leading-relaxed">
{pretty(revealJson ?? {})}
</pre>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
navigator.clipboard.writeText(pretty(revealJson ?? {}))
toast.success("Copied to clipboard")
}}
>
Copy
</Button>
<Button onClick={() => setRevealOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, type FC } from "react" import { type FC, useEffect, useState } from "react"
import { archerAdminApi } from "@/api/archer_admin" import { archerAdminApi } from "@/api/archer_admin"
import type { AdminListArcherJobsRequest } from "@/sdk" import type { AdminListArcherJobsRequest } from "@/sdk"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
@@ -19,21 +19,8 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
Select, import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
// Types (align with generated client camelCase) // Types (align with generated client camelCase)
@@ -145,8 +132,12 @@ export const JobsPage: FC = () => {
// Mutations // Mutations
const enqueueM = useMutation({ const enqueueM = useMutation({
mutationFn: (body: { queue: string; type: string; payload?: unknown; run_at?: string }) => mutationFn: (body: {
archerAdminApi.enqueue(body), queue: string
type: string
payload?: object | undefined
run_at?: string
}) => archerAdminApi.enqueue(body),
onSuccess: () => qc.invalidateQueries({ queryKey: ["archer", "jobs"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["archer", "jobs"] }),
}) })
const retryM = useMutation({ const retryM = useMutation({
@@ -462,7 +453,7 @@ function EnqueueDialog({
onSubmit: (body: { onSubmit: (body: {
queue: string queue: string
type: string type: string
payload?: unknown payload?: object | undefined
run_at?: string run_at?: string
}) => Promise<unknown> }) => Promise<unknown>
submitting?: boolean submitting?: boolean

View File

@@ -5,9 +5,11 @@ import {
ArcherAdminApi, ArcherAdminApi,
AuthApi, AuthApi,
Configuration, Configuration,
CredentialsApi,
LabelsApi, LabelsApi,
MeApi, MeApi,
MeAPIKeysApi, MeAPIKeysApi,
MetaApi,
NodePoolsApi, NodePoolsApi,
OrgsApi, OrgsApi,
ServersApi, ServersApi,
@@ -108,3 +110,11 @@ export function makeArcherAdminApi() {
export function makeNodePoolApi() { export function makeNodePoolApi() {
return makeApiClient(NodePoolsApi) return makeApiClient(NodePoolsApi)
} }
export function makeMetaApi() {
return makeApiClient(MetaApi)
}
export function makeCredentialsApi() {
return makeApiClient(CredentialsApi)
}

View File

@@ -763,11 +763,6 @@
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
"@pkgr/core@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b"
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
"@radix-ui/number@1.1.1": "@radix-ui/number@1.1.1":
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090"
@@ -2492,19 +2487,6 @@ escape-string-regexp@^4.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-config-prettier@10.1.8:
version "10.1.8"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
eslint-plugin-prettier@5.5.4:
version "5.5.4"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz#9d61c4ea11de5af704d4edf108c82ccfa7f2e61c"
integrity sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==
dependencies:
prettier-linter-helpers "^1.0.0"
synckit "^0.11.7"
eslint-plugin-react-hooks@7.0.1: eslint-plugin-react-hooks@7.0.1:
version "7.0.1" version "7.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz#66e258db58ece50723ef20cc159f8aa908219169" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz#66e258db58ece50723ef20cc159f8aa908219169"
@@ -2715,11 +2697,6 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-diff@^1.1.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
fast-equals@^5.0.1: fast-equals@^5.0.1:
version "5.3.2" version "5.3.2"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.3.2.tgz#75a9c7b1c2f627851349a2db94327d79b774ce83" resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.3.2.tgz#75a9c7b1c2f627851349a2db94327d79b774ce83"
@@ -3754,13 +3731,6 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier-linter-helpers@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
dependencies:
fast-diff "^1.1.2"
prettier-plugin-tailwindcss@0.7.1: prettier-plugin-tailwindcss@0.7.1:
version "0.7.1" version "0.7.1"
resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.1.tgz#0cb15246668788e62b5b752868f5e01f0ce7eec9" resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.1.tgz#0cb15246668788e62b5b752868f5e01f0ce7eec9"
@@ -4329,17 +4299,10 @@ supports-color@^7.1.0:
dependencies: dependencies:
has-flag "^4.0.0" has-flag "^4.0.0"
synckit@^0.11.7: tailwind-merge@^3.4.0:
version "0.11.11" version "3.4.0"
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.11.tgz#c0b619cf258a97faa209155d9cd1699b5c998cb0" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz#5a264e131a096879965f1175d11f8c36e6b64eca"
integrity sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw== integrity sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==
dependencies:
"@pkgr/core" "^0.2.9"
tailwind-merge@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.3.1.tgz#a7e7db7c714f6020319e626ecfb7e7dac8393a4b"
integrity sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==
tailwindcss@4.1.17, tailwindcss@^4.1.17: tailwindcss@4.1.17, tailwindcss@^4.1.17:
version "4.1.17" version "4.1.17"