feat(api): error codes, idempotency keys, cursor pagination

- Stable machine-readable error codes alongside the error message
  (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT,
  IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message".
- Idempotency middleware on authed/admin groups: POST requests with an
  Idempotency-Key header replay the first response for 24h (Redis-backed,
  caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay
  header marks replays.
- Shared pagination helpers: ?cursor= (opaque, preferred) alongside
  legacy ?page= on projects and deployments lists; pagination meta
  includes next_cursor; invalid cursors fail soft to page 1.
- CLI --idempotency-key flag for safe scripted retries.
- OpenAPI updated: error envelope, cursor params, idempotency contract.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 15:38:36 +02:00
1 parent 485e41a817
commit 18f78f00a3
15 files changed
+454 -56

No files matched your search

+32 -12
View File
@@ -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) {
+29
View File
@@ -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()
}
+52
View File
@@ -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
}
@@ -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")
}
}
+3 -19
View File
@@ -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)),
})
}
+2
View File
@@ -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)
+13 -13
View File
@@ -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
}
@@ -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,
+2
View File
@@ -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)
@@ -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)
}
@@ -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)
}
}
@@ -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
}