diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index 0b3b9d3..fb2f71c 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -6,7 +6,6 @@ import ( "context" "fmt" "net/http" - "strconv" "strings" "time" @@ -74,14 +73,21 @@ func handleGetDeployments(c *gin.Context) { return } + offset, limit := pageWindow(c, 50, 100) + + var total int + _ = db.(*database.DB).QueryRow( + `SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID, + ).Scan(&total) + rows, err := db.(*database.DB).Query( `SELECT id, service_id, commit_hash, status, image_name, image_tag, build_log, runtime_log, error, started_at, completed_at, created_at, updated_at FROM deployments WHERE service_id = $1 ORDER BY created_at DESC - LIMIT 50`, - serviceID, + LIMIT $2 OFFSET $3`, + serviceID, limit, offset, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) @@ -104,7 +110,10 @@ func handleGetDeployments(c *gin.Context) { deployments = append(deployments, d) } - c.JSON(http.StatusOK, gin.H{"deployments": deployments}) + c.JSON(http.StatusOK, gin.H{ + "deployments": deployments, + "pagination": paginationMeta(offset, limit, total), + }) } // RecentDeployment is a deployment row joined with its service and project names. @@ -132,11 +141,19 @@ func handleGetRecentDeployments(c *gin.Context) { userID := optionalUserUUID(c) isAdmin := contextIsAdmin(c) - limit := 10 - if v := c.Query("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 { - limit = n - } + offset, limit := pageWindow(c, 10, 50) + + var total int + if err := db.(*database.DB).QueryRow( + `SELECT COUNT(*) + FROM deployments d + JOIN services s ON s.id = d.service_id + JOIN projects p ON p.id = s.project_id + WHERE p.is_approved OR p.owner_id = $1 OR $2::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`, + userID, isAdmin, + ).Scan(&total); err != nil { + total = 0 } rows, err := db.(*database.DB).Query( @@ -148,8 +165,8 @@ func handleGetRecentDeployments(c *gin.Context) { WHERE p.is_approved OR p.owner_id = $1 OR $2::bool OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) ORDER BY d.created_at DESC - LIMIT $3`, - userID, isAdmin, limit, + LIMIT $3 OFFSET $4`, + userID, isAdmin, limit, offset, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) @@ -168,7 +185,10 @@ func handleGetRecentDeployments(c *gin.Context) { deployments = append(deployments, d) } - c.JSON(http.StatusOK, gin.H{"deployments": deployments}) + c.JSON(http.StatusOK, gin.H{ + "deployments": deployments, + "pagination": paginationMeta(offset, limit, total), + }) } func handleCreateDeployment(c *gin.Context) { diff --git a/app/backend/internal/api/errors.go b/app/backend/internal/api/errors.go new file mode 100644 index 0000000..571e279 --- /dev/null +++ b/app/backend/internal/api/errors.go @@ -0,0 +1,29 @@ +package api + +import ( + "github.com/gin-gonic/gin" +) + +// Stable machine-readable error codes for agent clients. The "error" field +// stays human-readable; "code" is the contract the CLI/MCP rely on. +const ( + CodeUnauthorized = "UNAUTHENTICATED" + CodeInvalidToken = "INVALID_TOKEN" + CodeForbidden = "FORBIDDEN" + CodeAdminRequired = "ADMIN_REQUIRED" + CodeScopeInsufficient = "SCOPE_INSUFFICIENT" + CodeNotFound = "NOT_FOUND" + CodeValidation = "VALIDATION" + CodeConflict = "CONFLICT" + CodeInternal = "INTERNAL" + CodeUnavailable = "DEPENDENCY_UNAVAILABLE" + CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS" +) + +// respondError emits the standard error envelope. Existing handlers return +// bare {"error": msg} — this adds the stable code for new and +// agent-facing paths. +func respondError(c *gin.Context, status int, code, message string) { + c.JSON(status, gin.H{"error": message, "code": code}) + c.Abort() +} diff --git a/app/backend/internal/api/pagination.go b/app/backend/internal/api/pagination.go new file mode 100644 index 0000000..88e779e --- /dev/null +++ b/app/backend/internal/api/pagination.go @@ -0,0 +1,52 @@ +package api + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// pageWindow parses pagination from either ?page=&limit= (legacy) or +// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset — +// cheap to implement over the existing offset queries while the public +// contract stays cursor-shaped for clients. +func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) { + limit = defaultLimit + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit { + limit = n + } + } + if cur := c.Query("cursor"); cur != "" { + if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil { + if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 { + return n, limit + } + } + return 0, limit // invalid cursor → first page, fail soft + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + return (page - 1) * limit, limit +} + +// paginationMeta renders the standard envelope: legacy page fields plus +// next_cursor when another page exists. +func paginationMeta(offset, limit, total int) gin.H { + page := offset/limit + 1 + meta := gin.H{ + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) / limit, + } + if offset+limit < total { + meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit))) + } + return meta +} diff --git a/app/backend/internal/api/pagination_test.go b/app/backend/internal/api/pagination_test.go new file mode 100644 index 0000000..15a7acc --- /dev/null +++ b/app/backend/internal/api/pagination_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func ctxWithQuery(q string) *gin.Context { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/x?"+q, nil) + return c +} + +func TestPageWindowDefaults(t *testing.T) { + offset, limit := pageWindow(ctxWithQuery(""), 10, 100) + if offset != 0 || limit != 10 { + t.Fatalf("expected 0/10, got %d/%d", offset, limit) + } +} + +func TestPageWindowLegacyPage(t *testing.T) { + offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100) + if offset != 40 || limit != 20 { + t.Fatalf("expected 40/20, got %d/%d", offset, limit) + } +} + +func TestPageWindowCursor(t *testing.T) { + // cursor for offset 50 produced by paginationMeta + meta := paginationMeta(0, 50, 200) + cursor := meta["next_cursor"].(string) + offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100) + if offset != 50 || limit != 50 { + t.Fatalf("expected 50/50, got %d/%d", offset, limit) + } +} + +func TestPageWindowInvalidCursorFailsSoft(t *testing.T) { + offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100) + if offset != 0 { + t.Fatalf("expected offset 0 on invalid cursor, got %d", offset) + } +} + +func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) { + if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok { + t.Fatal("no next page should omit next_cursor") + } + if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok { + t.Fatal("expected next_cursor when more pages exist") + } +} diff --git a/app/backend/internal/api/projects.go b/app/backend/internal/api/projects.go index f5eebc8..47f06d3 100644 --- a/app/backend/internal/api/projects.go +++ b/app/backend/internal/api/projects.go @@ -8,7 +8,6 @@ import ( "database/sql" "errors" "net/http" - "strconv" "strings" "time" @@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) { db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + offset, limit := pageWindow(c, 10, 100) search := strings.TrimSpace(c.DefaultQuery("search", "")) - if page < 1 { - page = 1 - } - if limit > 100 || limit < 1 { - limit = 10 - } - offset := (page - 1) * limit - searchParam := sql.NullString{} if search != "" { searchParam = sql.NullString{String: search, Valid: true} @@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) { return } - totalCount := int(total) c.JSON(http.StatusOK, gin.H{ - "projects": projects, - "pagination": gin.H{ - "page": page, - "limit": limit, - "total": totalCount, - "pages": (totalCount + limit - 1) / limit, - }, + "projects": projects, + "pagination": paginationMeta(offset, limit, int(total)), }) } diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 9bb7d58..18ccd1c 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -256,6 +256,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // enforce ownership; platform admins additionally pass every check. authed := v1.Group("/") authed.Use(middleware.Auth(cfg.JWTSecret)) + authed.Use(middleware.Idempotency()) { authed.GET("/user/profile", handleGetProfile) authed.PUT("/user/profile", handleUpdateProfile) @@ -357,6 +358,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Admin routes — platform-wide controls and cross-user management. admin := v1.Group("/") admin.Use(middleware.Auth(cfg.JWTSecret)) + admin.Use(middleware.Idempotency()) admin.Use(middleware.RequireAdmin()) { admin.GET("/admin/overview", handleAdminOverview) diff --git a/app/backend/internal/api/user_tokens.go b/app/backend/internal/api/user_tokens.go index 86e819b..408f120 100644 --- a/app/backend/internal/api/user_tokens.go +++ b/app/backend/internal/api/user_tokens.go @@ -72,36 +72,36 @@ func handleCreateUserToken(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) if req.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"}) return } if req.Scope == "" { req.Scope = "write" } if !userTokenScopes[req.Scope] { - c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"}) return } // A token can never outrank its owner: non-admin users cannot mint // admin-scope tokens even for themselves. if req.Scope == "admin" && !c.GetBool("is_admin") { - c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account"}) + c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"}) return } if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 { - c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"}) return } token, prefix, hash, err := generateUserToken() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"}) return } ownerUUID, err := uuid.Parse(userID) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"}) return } @@ -119,7 +119,7 @@ func handleCreateUserToken(c *gin.Context) { ExpiresAt: expiresAt, }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"}) return } @@ -140,13 +140,13 @@ func handleListUserTokens(c *gin.Context) { ownerUUID, err := uuid.Parse(userID) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"}) return } rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"}) return } @@ -167,12 +167,12 @@ func handleDeleteUserToken(c *gin.Context) { ownerUUID, err := uuid.Parse(userID) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"}) return } tokenUUID, err := uuid.Parse(c.Param("id")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"}) return } @@ -181,11 +181,11 @@ func handleDeleteUserToken(c *gin.Context) { UserID: ownerUUID, }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"}) return } if affected == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"}) return } diff --git a/app/backend/internal/cli/commands/client.go b/app/backend/internal/cli/commands/client.go index 776cc9a..1af86e0 100644 --- a/app/backend/internal/cli/commands/client.go +++ b/app/backend/internal/cli/commands/client.go @@ -8,6 +8,8 @@ import ( "net/http" "strings" "time" + + "github.com/spf13/viper" ) // Exit codes — stable for scripting and agent use. @@ -109,6 +111,9 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) { if body != nil { req.Header.Set("Content-Type", "application/json") } + if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost { + req.Header.Set("Idempotency-Key", k) + } req.Header.Set("Accept", "application/json") hc := c.HTTPClient @@ -134,9 +139,13 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) { msg := strings.TrimSpace(string(data)) var envelope struct { Error string `json:"error"` + Code string `json:"code"` } if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" { msg = envelope.Error + if envelope.Code != "" { + msg = envelope.Code + ": " + msg + } } return nil, &APIError{ Status: resp.StatusCode, diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index a38c2a8..b17525c 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -55,12 +55,14 @@ func init() { pf.StringP("profile", "p", "", "config profile to use (default current_profile)") pf.Bool("json", false, "machine-readable JSON output") pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)") + pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)") viper.BindPFlag("api-url", pf.Lookup("api-url")) viper.BindPFlag("token", pf.Lookup("token")) viper.BindPFlag("profile", pf.Lookup("profile")) viper.BindPFlag("json", pf.Lookup("json")) viper.BindPFlag("yes", pf.Lookup("yes")) + viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key")) rootCmd.AddCommand(commands.AuthCmd) rootCmd.AddCommand(commands.TokensCmd) diff --git a/app/backend/internal/middleware/idempotency.go b/app/backend/internal/middleware/idempotency.go new file mode 100644 index 0000000..c0726a6 --- /dev/null +++ b/app/backend/internal/middleware/idempotency.go @@ -0,0 +1,132 @@ +package middleware + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "time" + + "containr/internal/database" + + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" +) + +// Idempotency replays POST responses for a repeated Idempotency-Key header +// so agents can safely retry mutations. The first request wins; concurrent +// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay +// verbatim for 24h. Keys are scoped to the authenticated identity. +func Idempotency() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method != http.MethodPost { + c.Next() + return + } + key := c.GetHeader("Idempotency-Key") + if key == "" || len(key) > 128 { + c.Next() + return + } + dbValue, exists := c.Get("redis") + if !exists { + c.Next() + return + } + rdb, ok := dbValue.(*database.Redis) + if !ok || rdb == nil || rdb.Client == nil { + c.Next() + return + } + + // Scope by caller identity so a stolen key can't replay across users. + actor := c.GetString("user_id") + if actor == "" { + actor = c.GetHeader("Authorization") + } + sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key)) + redisKey := "idem:" + hex.EncodeToString(sum[:16]) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + stored, err := rdb.Client.Get(ctx, redisKey).Result() + switch { + case err == nil: + if stored == "processing" { + c.JSON(http.StatusConflict, gin.H{ + "error": "a request with this idempotency key is still in progress", + "code": "IDEMPOTENCY_IN_PROGRESS", + }) + c.Abort() + return + } + var rec idemRecord + if json.Unmarshal([]byte(stored), &rec) == nil { + c.Header("X-Idempotent-Replay", "true") + c.Data(rec.Status, rec.ContentType, rec.Body) + c.Abort() + return + } + case err != redis.Nil: + // Redis hiccup — degrade to pass-through rather than failing the request. + c.Next() + return + } + + // Claim the key for the duration of the request. + if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set { + c.JSON(http.StatusConflict, gin.H{ + "error": "a request with this idempotency key is still in progress", + "code": "IDEMPOTENCY_IN_PROGRESS", + }) + c.Abort() + return + } + + recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}} + c.Writer = recorder + c.Next() + + // Cache terminal responses only — 5xx is retryable and must not be replayed. + if recorder.status < 500 && recorder.body.Len() <= 1<<20 { + rec := idemRecord{ + Status: recorder.status, + ContentType: recorder.Header().Get("Content-Type"), + Body: recorder.body.Bytes(), + } + if rec.ContentType == "" { + rec.ContentType = "application/json; charset=utf-8" + } + if raw, merr := json.Marshal(rec); merr == nil { + _ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err() + } + } else { + _ = rdb.Client.Del(context.Background(), redisKey).Err() + } + } +} + +type idemRecord struct { + Status int `json:"s"` + ContentType string `json:"ct"` + Body []byte `json:"b"` +} + +type responseRecorder struct { + gin.ResponseWriter + status int + body *bytes.Buffer +} + +func (r *responseRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func (r *responseRecorder) Write(b []byte) (int, error) { + r.body.Write(b) + return r.ResponseWriter.Write(b) +} diff --git a/app/backend/internal/middleware/idempotency_test.go b/app/backend/internal/middleware/idempotency_test.go new file mode 100644 index 0000000..33a2263 --- /dev/null +++ b/app/backend/internal/middleware/idempotency_test.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestIdempotencyPassThroughWithoutKey(t *testing.T) { + gin.SetMode(gin.TestMode) + called := 0 + router := gin.New() + router.Use(Idempotency()) + router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) }) + + req := httptest.NewRequest(http.MethodPost, "/x", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if called != 1 || rec.Code != 200 { + t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code) + } +} + +func TestIdempotencyPassThroughWithoutRedis(t *testing.T) { + gin.SetMode(gin.TestMode) + called := 0 + router := gin.New() + router.Use(Idempotency()) + router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) }) + + // Key present but no redis in context — must not block the request. + req := httptest.NewRequest(http.MethodPost, "/x", nil) + req.Header.Set("Idempotency-Key", "abc-123") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if called != 1 || rec.Code != 200 { + t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code) + } +} + +func TestIdempotencySkipsNonPOST(t *testing.T) { + gin.SetMode(gin.TestMode) + called := 0 + router := gin.New() + router.Use(Idempotency()) + router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Idempotency-Key", "abc") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if called != 1 { + t.Fatalf("GET should bypass idempotency, called=%d", called) + } +} diff --git a/app/backend/internal/middleware/middleware.go b/app/backend/internal/middleware/middleware.go index 9549959..6c468a0 100644 --- a/app/backend/internal/middleware/middleware.go +++ b/app/backend/internal/middleware/middleware.go @@ -212,15 +212,15 @@ func Auth(jwtSecret string) gin.HandlerFunc { } if tokenErr != "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr}) + c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"}) c.Abort() return } if hasToken { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"}) } else { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"}) } c.Abort() } @@ -245,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc { func RequireAdmin() gin.HandlerFunc { return func(c *gin.Context) { if _, exists := c.Get("user_id"); !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"}) c.Abort() return } if !c.GetBool("is_admin") { - c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"}) c.Abort() return } diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 04d56fa..188c66b 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -392,10 +392,12 @@ export interface paths { get: { parameters: { query?: { - /** @description Page number for pagination */ + /** @description Page number for pagination (legacy — prefer cursor) */ page?: number; /** @description Number of items per page */ limit?: number; + /** @description Opaque cursor from a previous response's pagination.next_cursor */ + cursor?: string; /** @description Search term to filter projects */ search?: string; }; @@ -982,7 +984,11 @@ export interface paths { */ get: { parameters: { - query?: never; + query?: { + limit?: number; + /** @description Opaque cursor from a previous response's pagination.next_cursor */ + cursor?: string; + }; header?: never; path: { /** @description Service ID */ @@ -1056,6 +1062,8 @@ export interface paths { parameters: { query?: { limit?: number; + /** @description Opaque cursor from a previous response's pagination.next_cursor */ + cursor?: string; }; header?: never; path?: never; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 16968f3..b8e2edf 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -36,6 +36,12 @@ export const bundledDocs: BundledDoc[] = [ "title": "🚀 Zero-Interaction Docker Deployment System", "markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Directory Structure**\n\n```\nyour-project/\n├── scripts/\n│ ├── auto-deploy.sh # 🚀 Main auto-deployment script\n│ └── quick-deploy.sh # ⚡ Quick start script\n├── templates/\n│ ├── umami.md\n│ ├── plex.md\n│ └── ...\n└── deployments/ # 📁 Auto-created\n ├── glance/ # 📁 Template deployment\n │ ├── docker-compose.yml\n │ ├── .env\n │ ├── data/\n │ └── logs/\n ├── umami/\n └── plex/\n```\n\n## 🎯 **Template Difficulty Levels**\n\n| **Easy** (Auto-Deploy) | **Medium** (Auto-Deploy) | **Hard** (Auto-Deploy) |\n|------------------------|-------------------------|----------------------|\n| 🌐 Glance | 📊 Umami | 🗄️ Supabase |\n| 📝 Memos | 📈 Uptime Kuma | 🏠 Home Assistant |\n| 🔍 MeiliSearch | 🛡️ Vaultwarden | 📊 Grafana |\n| 🚪 Pi-hole | 📁 Cloudreve | 🐙 Gitea |\n| | 🌐 Traefik | 🦣 Mastodon |\n| | 🎬 Jellyfin | ☁️ Nextcloud |\n| | 📚 Plex | 🎬 Immich |\n| | 🔄 n8n | |\n\n## 🚀 **Ready to Use?**\n\n**Yes! The system is fully automatic. Just run:**\n\n```bash\n# Quick start (easiest)\n./scripts/quick-deploy.sh\n\n# Or direct deployment\n./scripts/auto-deploy.sh glance\n```\n\n**That's it!** 🎉\n\nThe system will:\n- ✅ **Install Docker** if needed\n- ✅ **Deploy your chosen service**\n- ✅ **Generate all secrets**\n- ✅ **Handle all configuration**\n- ✅ **Show you the access URL**\n- ✅ **Provide management commands**\n\n**Zero user interaction required!** 🚀\n" }, + { + "path": "guides/CLI.md", + "section": "guides", + "title": "Containr CLI", + "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|delete|start|stop|restart|redeploy\ndeploy trigger a deployment\ndeployments list|get|logs|rollback\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n" + }, { "path": "guides/CLOUDFLARE_SETUP.md", "section": "guides", diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8869033..4f730a6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8,10 +8,27 @@ info: Containr provides a comprehensive platform for deploying, managing, and scaling containerized applications on Proxmox infrastructure. This API allows you to manage projects, services, deployments, databases, and more through a RESTful interface. ## Authentication - The API uses JWT-based authentication. Include your JWT token in the Authorization header: - ``` - Authorization: Bearer - ``` + The API accepts three credentials in the Authorization header: + `Authorization: Bearer ` (UI sessions), a Better Auth session cookie, or a + personal access token `cnp_...` (CLI/MCP/agents). PAT scopes: `read` allows only + GET/HEAD/OPTIONS; `write` everything except admin routes; `admin` requires a + platform-admin owner. + + ## Errors + Error responses use `{"error": "human message", "code": "MACHINE_CODE"}`. Stable + codes: `UNAUTHENTICATED`, `INVALID_TOKEN`, `FORBIDDEN`, `ADMIN_REQUIRED`, + `SCOPE_INSUFFICIENT`, `NOT_FOUND`, `VALIDATION`, `CONFLICT`, `INTERNAL`, + `DEPENDENCY_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`. + + ## Idempotency + POST endpoints accept an `Idempotency-Key` header. The first response is replayed + for repeated keys within 24h (scoped to the caller and path); a concurrent duplicate + receives `409 IDEMPOTENCY_IN_PROGRESS`. Safe for clients to always send a UUID. + + ## Pagination + List endpoints accept `?limit=` plus either `?page=` (legacy) or `?cursor=` + (preferred — pass `pagination.next_cursor` from the previous response verbatim). + Invalid cursors fail soft to the first page. ## Base URL ``` @@ -255,7 +272,7 @@ paths: parameters: - name: page in: query - description: Page number for pagination + description: Page number for pagination (legacy — prefer cursor) schema: type: integer default: 1 @@ -265,6 +282,11 @@ paths: schema: type: integer default: 20 + - name: cursor + in: query + description: Opaque cursor from a previous response's pagination.next_cursor + schema: + type: string - name: search in: query description: Search term to filter projects @@ -660,6 +682,17 @@ paths: description: Service ID schema: type: string + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 100 + - name: cursor + in: query + description: Opaque cursor from a previous response's pagination.next_cursor + schema: + type: string responses: '200': description: Deployments retrieved successfully @@ -712,6 +745,12 @@ paths: type: integer default: 10 maximum: 50 + - name: cursor + in: query + required: false + description: Opaque cursor from a previous response's pagination.next_cursor + schema: + type: string responses: '200': description: Recent deployments