From e8817473880e0c0b1e3e0f8b3707e2b639ddec46 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 8 Oct 2026 14:31:29 +0200 Subject: [PATCH 1/2] feat: personal access tokens with scoped bearer auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cnp_ personal access tokens so the CLI, MCP server, and external agents authenticate without a browser session — the agent-first spine from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown once, only SHA-256 hash stored. - user_tokens table (goose migration + sqlc), name/prefix/scope/expiry, owner-scoped revoke, last_used_at stamping - middleware: cnp_ bearer resolution, read scope gates mutations with 403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin owners — a token never outranks its scope or its owner - GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types - Settings UI: create with scope/expiry pickers, one-time token display with copy, list with last-used/expiry, confirm-revoke - docs: OPERATIONS.md section, README feature line Verified end-to-end against live Postgres: create/list/revoke, read scope blocks PUT (403), write PAT on admin owner blocked from admin routes (403), admin scope works, revoked token returns 401. Authored By: TDvorak --- README.md | 2 +- app/backend/internal/api/routes.go | 5 + app/backend/internal/api/user_tokens.go | 194 ++++++++++++++++++ .../internal/database/sqlcdb/models.go | 73 ++++--- .../internal/database/sqlcdb/querier.go | 5 + .../database/sqlcdb/user_tokens.sql.go | 147 +++++++++++++ app/backend/internal/middleware/middleware.go | 76 ++++++- .../internal/middleware/middleware_test.go | 55 +++++ .../20261008000000_user_tokens.sql | 25 +++ app/backend/sqlc/queries/user_tokens.sql | 24 +++ app/backend/sqlc/schema.sql | 13 ++ app/frontend/src/features/secondary/pages.tsx | 166 +++++++++++++++ app/frontend/src/generated/api-types.ts | 178 ++++++++++++++++ app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 28 +++ docs/api/openapi.yaml | 151 ++++++++++++++ docs/guides/OPERATIONS.md | 27 +++ 17 files changed, 1141 insertions(+), 30 deletions(-) create mode 100644 app/backend/internal/api/user_tokens.go create mode 100644 app/backend/internal/database/sqlcdb/user_tokens.sql.go create mode 100644 app/backend/migrations_goose/20261008000000_user_tokens.sql create mode 100644 app/backend/sqlc/queries/user_tokens.sql diff --git a/README.md b/README.md index 0498a87..d0b9fff 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ dedicated console. - **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **HA & scaling** — failover policies, node agents, autoscaling rules -- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans +- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans - **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 2114bd0..9bb7d58 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -260,6 +260,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.GET("/user/profile", handleGetProfile) authed.PUT("/user/profile", handleUpdateProfile) + // Personal access tokens for CLI/MCP/agent auth. + authed.GET("/user/tokens", handleListUserTokens) + authed.POST("/user/tokens", handleCreateUserToken) + authed.DELETE("/user/tokens/:id", handleDeleteUserToken) + authed.GET("/notifications", handleListNotifications) authed.POST("/notifications/:id/read", handleMarkNotificationRead) authed.POST("/notifications/read-all", handleMarkAllNotificationsRead) diff --git a/app/backend/internal/api/user_tokens.go b/app/backend/internal/api/user_tokens.go new file mode 100644 index 0000000..86e819b --- /dev/null +++ b/app/backend/internal/api/user_tokens.go @@ -0,0 +1,194 @@ +package api + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "net/http" + "strings" + "time" + + "containr/internal/database" + sqlcdb "containr/internal/database/sqlcdb" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Personal access tokens let the CLI, MCP server, and third-party agents +// authenticate without a browser session. The raw token (cnp_…) is +// returned exactly once at creation; only its sha256 hash persists. + +var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true} + +func generateUserToken() (token string, prefix string, hash string, err error) { + buf := make([]byte, 24) + if _, err = rand.Read(buf); err != nil { + return "", "", "", err + } + token = "cnp_" + hex.EncodeToString(buf) + prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list + sum := sha256.Sum256([]byte(token)) + return token, prefix, hex.EncodeToString(sum[:]), nil +} + +func userTokenJSON(t sqlcdb.UserToken) gin.H { + row := gin.H{ + "id": t.ID.String(), + "name": t.Name, + "key_prefix": t.KeyPrefix, + "scope": t.Scope, + } + if t.CreatedAt.Valid { + row["created_at"] = t.CreatedAt.Time + } + if t.LastUsedAt.Valid { + row["last_used_at"] = t.LastUsedAt.Time + } + if t.ExpiresAt.Valid { + row["expires_at"] = t.ExpiresAt.Time + } + return row +} + +func handleCreateUserToken(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + + var req struct { + Name string `json:"name"` + Scope string `json:"scope"` + ExpiresInDays int `json:"expires_in_days"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + 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"}) + 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"}) + 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"}) + return + } + + token, prefix, hash, err := generateUserToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + ownerUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + return + } + + expiresAt := sql.NullTime{} + if req.ExpiresInDays > 0 { + expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true} + } + + row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{ + UserID: ownerUUID, + Name: req.Name, + KeyPrefix: prefix, + TokenHash: hash, + Scope: req.Scope, + ExpiresAt: expiresAt, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"}) + return + } + + LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope}) + + payload := userTokenJSON(row) + payload["token"] = token + c.JSON(http.StatusCreated, payload) +} + +func handleListUserTokens(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + + ownerUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + return + } + + rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"}) + return + } + + out := make([]gin.H, 0, len(rows)) + for _, t := range rows { + out = append(out, userTokenJSON(t)) + } + c.JSON(http.StatusOK, gin.H{"tokens": out}) +} + +func handleDeleteUserToken(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + + ownerUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + return + } + tokenUUID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID"}) + return + } + + affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{ + ID: tokenUUID, + UserID: ownerUUID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"}) + return + } + if affected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"}) + return + } + + LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil) + c.JSON(http.StatusOK, gin.H{"message": "Token revoked"}) +} diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index b424a62..d1521eb 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -168,33 +168,39 @@ type ProjectMember struct { } type Service struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Description sql.NullString `json:"description"` - ProjectID uuid.UUID `json:"project_id"` - EnvironmentID uuid.UUID `json:"environment_id"` - ServiceType string `json:"service_type"` - SourceType string `json:"source_type"` - SourceUrl sql.NullString `json:"source_url"` - ImageName sql.NullString `json:"image_name"` - BuildCommand sql.NullString `json:"build_command"` - StartCommand sql.NullString `json:"start_command"` - CpuLimit sql.NullInt32 `json:"cpu_limit"` - MemoryLimit sql.NullInt32 `json:"memory_limit"` - PublicUrl sql.NullString `json:"public_url"` - HealthCheckUrl sql.NullString `json:"health_check_url"` - Status sql.NullString `json:"status"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` - Type sql.NullString `json:"type"` - Image sql.NullString `json:"image"` - Command sql.NullString `json:"command"` - Environment sql.NullString `json:"environment"` - GitRepo sql.NullString `json:"git_repo"` - GitBranch sql.NullString `json:"git_branch"` - BuildPath sql.NullString `json:"build_path"` - Cpu sql.NullString `json:"cpu"` - Memory sql.NullString `json:"memory"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description sql.NullString `json:"description"` + ProjectID uuid.UUID `json:"project_id"` + EnvironmentID uuid.UUID `json:"environment_id"` + ServiceType string `json:"service_type"` + SourceType string `json:"source_type"` + SourceUrl sql.NullString `json:"source_url"` + ImageName sql.NullString `json:"image_name"` + BuildCommand sql.NullString `json:"build_command"` + StartCommand sql.NullString `json:"start_command"` + CpuLimit sql.NullInt32 `json:"cpu_limit"` + MemoryLimit sql.NullInt32 `json:"memory_limit"` + PublicUrl sql.NullString `json:"public_url"` + HealthCheckUrl sql.NullString `json:"health_check_url"` + Status sql.NullString `json:"status"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + Type sql.NullString `json:"type"` + Image sql.NullString `json:"image"` + Command sql.NullString `json:"command"` + Environment sql.NullString `json:"environment"` + GitRepo sql.NullString `json:"git_repo"` + GitBranch sql.NullString `json:"git_branch"` + BuildPath sql.NullString `json:"build_path"` + Cpu sql.NullString `json:"cpu"` + Memory sql.NullString `json:"memory"` + Replicas int32 `json:"replicas"` + Port int32 `json:"port"` + Domain string `json:"domain"` + HealthcheckPath string `json:"healthcheck_path"` + RestartPolicy string `json:"restart_policy"` + PublishedPort int32 `json:"published_port"` } type ServiceTemplate struct { @@ -211,3 +217,16 @@ type ServiceTemplate struct { CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` } + +type UserToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + TokenHash string `json:"token_hash"` + Scope string `json:"scope"` + ExpiresAt sql.NullTime `json:"expires_at"` + LastUsedAt sql.NullTime `json:"last_used_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt sql.NullTime `json:"created_at"` +} diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 1f1ef83..343dea9 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -26,6 +26,7 @@ type Querier interface { CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error + CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DeleteAgent(ctx context.Context, id string) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error @@ -46,6 +47,7 @@ type Querier interface { GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) + GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) @@ -66,9 +68,11 @@ type Querier interface { ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) + ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) + RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error @@ -77,6 +81,7 @@ type Querier interface { SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) TouchAgentAuthToken(ctx context.Context, tokenHash string) error + TouchUserToken(ctx context.Context, id uuid.UUID) error UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error diff --git a/app/backend/internal/database/sqlcdb/user_tokens.sql.go b/app/backend/internal/database/sqlcdb/user_tokens.sql.go new file mode 100644 index 0000000..c2a8d71 --- /dev/null +++ b/app/backend/internal/database/sqlcdb/user_tokens.sql.go @@ -0,0 +1,147 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: user_tokens.sql + +package sqlcdb + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +const createUserToken = `-- name: CreateUserToken :one +INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at +` + +type CreateUserTokenParams struct { + UserID uuid.UUID `json:"user_id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + TokenHash string `json:"token_hash"` + Scope string `json:"scope"` + ExpiresAt sql.NullTime `json:"expires_at"` +} + +func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) { + row := q.db.QueryRowContext(ctx, createUserToken, + arg.UserID, + arg.Name, + arg.KeyPrefix, + arg.TokenHash, + arg.Scope, + arg.ExpiresAt, + ) + var i UserToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyPrefix, + &i.TokenHash, + &i.Scope, + &i.ExpiresAt, + &i.LastUsedAt, + &i.RevokedAt, + &i.CreatedAt, + ) + return i, err +} + +const getUserTokenByHash = `-- name: GetUserTokenByHash :one +SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens +WHERE token_hash = $1 + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW()) +LIMIT 1 +` + +func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) { + row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash) + var i UserToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyPrefix, + &i.TokenHash, + &i.Scope, + &i.ExpiresAt, + &i.LastUsedAt, + &i.RevokedAt, + &i.CreatedAt, + ) + return i, err +} + +const listUserTokens = `-- name: ListUserTokens :many +SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens +WHERE user_id = $1 AND revoked_at IS NULL +ORDER BY created_at DESC +` + +func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) { + rows, err := q.db.QueryContext(ctx, listUserTokens, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []UserToken{} + for rows.Next() { + var i UserToken + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyPrefix, + &i.TokenHash, + &i.Scope, + &i.ExpiresAt, + &i.LastUsedAt, + &i.RevokedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeUserToken = `-- name: RevokeUserToken :execrows +UPDATE user_tokens SET revoked_at = NOW() +WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL +` + +type RevokeUserTokenParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) { + result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const touchUserToken = `-- name: TouchUserToken :exec +UPDATE user_tokens SET last_used_at = NOW() +WHERE id = $1 AND revoked_at IS NULL +` + +func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, touchUserToken, id) + return err +} diff --git a/app/backend/internal/middleware/middleware.go b/app/backend/internal/middleware/middleware.go index dafbd8c..9549959 100644 --- a/app/backend/internal/middleware/middleware.go +++ b/app/backend/internal/middleware/middleware.go @@ -2,7 +2,10 @@ package middleware import ( "containr/internal/database" + sqlcdb "containr/internal/database/sqlcdb" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc { func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) { tokenString, tokenErr, hasToken := extractJWTToken(c) if tokenString != "" { + // Personal access tokens (cnp_…) are not JWTs — they resolve + // against the user_tokens table by sha256 hash. + if strings.HasPrefix(tokenString, patPrefix) { + if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified { + return id, mail, "", hasToken, true + } + return "", "", "Invalid token", hasToken, false + } if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid { userIDClaim, exists := claims["user_id"] if exists { @@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette return "", "", tokenErr, hasToken, false } +const patPrefix = "cnp_" + +// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the +// user_tokens table (sha256 hash match, not revoked, not expired). On success +// it stamps last_used_at, records the token scope on the context, and +// returns the owner's id and email. +func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) { + dbValue, exists := c.Get("db") + if !exists { + return "", "", false + } + db, ok := dbValue.(*database.DB) + if !ok || db == nil || db.DB == nil { + return "", "", false + } + + sum := sha256.Sum256([]byte(token)) + queries := sqlcdb.New(db.DB) + row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:])) + if err != nil { + return "", "", false + } + + var email string + if err := db.QueryRowContext(c.Request.Context(), + `SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil { + return "", "", false + } + + _ = queries.TouchUserToken(c.Request.Context(), row.ID) + c.Set("pat_scope", row.Scope) + return row.UserID.String(), email, true +} + +// patScopeAllows reports whether a PAT-authenticated request may proceed. +// read scope permits only safe methods; write and admin are unrestricted at +// this layer (admin routes still require the admin scope AND a platform +// admin account — enforced via is_admin in setAuthenticatedContext). +func patScopeAllows(c *gin.Context) bool { + if c.GetString("pat_scope") != "read" { + return true + } + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + } + return false +} + // setAuthenticatedContext stores the resolved identity plus the admin flag on // the gin context so handlers and RequireAdmin can rely on it. func setAuthenticatedContext(c *gin.Context, userID, email string) { c.Set("user_id", userID) c.Set("email", email) - c.Set("is_admin", loadIsAdmin(c, userID)) + isAdmin := loadIsAdmin(c, userID) + // A PAT can never outrank its scope: non-admin scopes cap the token at + // non-admin even when the owning user is a platform admin. + if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" { + isAdmin = false + } + c.Set("is_admin", isAdmin) } func loadIsAdmin(c *gin.Context, userID string) bool { @@ -133,6 +199,14 @@ func Auth(jwtSecret string) gin.HandlerFunc { userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier) if ok { setAuthenticatedContext(c, userID, email) + if !patScopeAllows(c) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Token scope is read-only", + "code": "SCOPE_INSUFFICIENT", + }) + c.Abort() + return + } c.Next() return } diff --git a/app/backend/internal/middleware/middleware_test.go b/app/backend/internal/middleware/middleware_test.go index 103d9e1..bda5f47 100644 --- a/app/backend/internal/middleware/middleware_test.go +++ b/app/backend/internal/middleware/middleware_test.go @@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) { } } +func TestAuthRejectsPATWithoutDatabase(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(Auth("secret")) + router.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + // A cnp_-prefixed token takes the PAT path, not JWT parsing — without a + // db handle it must fail closed with 401. + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } +} + +func TestPATScopeAllows(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + name string + scope string + method string + want bool + }{ + {"read allows GET", "read", http.MethodGet, true}, + {"read allows HEAD", "read", http.MethodHead, true}, + {"read allows OPTIONS", "read", http.MethodOptions, true}, + {"read blocks POST", "read", http.MethodPost, false}, + {"read blocks DELETE", "read", http.MethodDelete, false}, + {"write allows POST", "write", http.MethodPost, true}, + {"write allows DELETE", "write", http.MethodDelete, true}, + {"admin allows PATCH", "admin", http.MethodPatch, true}, + {"no scope (session/JWT) unrestricted", "", http.MethodPost, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(tc.method, "/x", nil) + if tc.scope != "" { + c.Set("pat_scope", tc.scope) + } + if got := patScopeAllows(c); got != tc.want { + t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want) + } + }) + } +} + func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) diff --git a/app/backend/migrations_goose/20261008000000_user_tokens.sql b/app/backend/migrations_goose/20261008000000_user_tokens.sql new file mode 100644 index 0000000..7f15a92 --- /dev/null +++ b/app/backend/migrations_goose/20261008000000_user_tokens.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Personal access tokens for CLI/MCP/agent authentication. +-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at +-- creation and never persisted. Scope gates what the token may do: +-- read = GET/HEAD only, write = all non-admin routes, admin = everything +-- (admin still requires the owning user to be a platform admin). +CREATE TABLE IF NOT EXISTS public.user_tokens ( + id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + name character varying(255) NOT NULL, + key_prefix character varying(16) NOT NULL, + token_hash text NOT NULL UNIQUE, + scope character varying(20) NOT NULL DEFAULT 'write' + CHECK (scope IN ('read', 'write', 'admin')), + expires_at timestamp with time zone, + last_used_at timestamp with time zone, + revoked_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash); + +-- +goose Down +DROP TABLE IF EXISTS public.user_tokens; diff --git a/app/backend/sqlc/queries/user_tokens.sql b/app/backend/sqlc/queries/user_tokens.sql new file mode 100644 index 0000000..c7a5b69 --- /dev/null +++ b/app/backend/sqlc/queries/user_tokens.sql @@ -0,0 +1,24 @@ +-- name: CreateUserToken :one +INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: ListUserTokens :many +SELECT * FROM user_tokens +WHERE user_id = $1 AND revoked_at IS NULL +ORDER BY created_at DESC; + +-- name: GetUserTokenByHash :one +SELECT * FROM user_tokens +WHERE token_hash = $1 + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW()) +LIMIT 1; + +-- name: TouchUserToken :exec +UPDATE user_tokens SET last_used_at = NOW() +WHERE id = $1 AND revoked_at IS NULL; + +-- name: RevokeUserToken :execrows +UPDATE user_tokens SET revoked_at = NOW() +WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index b85fe2a..564a2a4 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -204,3 +204,16 @@ CREATE TABLE notifications ( read_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + +CREATE TABLE user_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + key_prefix VARCHAR(16) NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + scope VARCHAR(20) NOT NULL DEFAULT 'write', + expires_at TIMESTAMP WITH TIME ZONE, + last_used_at TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index af48e90..30f2488 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -17,6 +17,9 @@ import { createAgentToken, listAgentTokens, revokeAgentToken, + createUserToken, + listUserTokens, + revokeUserToken, listDatabases, databaseAction, updateDatabaseBackupSchedule, @@ -46,6 +49,8 @@ import { updateVulnerability, getSecurityMetrics, type AgentAuthTokenCreated, + type UserTokenCreated, + type UserTokenScope, type CreateDatabaseInput, type DatabaseEntity, type FailoverPolicy, @@ -1267,6 +1272,164 @@ function PlatformSettingsSection() { ); } +const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [ + { label: 'Never', days: 0 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, + { label: '1 year', days: 365 }, +]; + +function UserTokensSection({ isAdmin }: { isAdmin: boolean }) { + const queryClient = useQueryClient(); + const tokensQuery = useQuery({ + queryKey: ['user-tokens'], + queryFn: listUserTokens, + }); + const [tokenName, setTokenName] = useState(''); + const [tokenScope, setTokenScope] = useState('write'); + const [expiryDays, setExpiryDays] = useState(0); + const [issuedToken, setIssuedToken] = useState(null); + + const createMutation = useMutation({ + mutationFn: () => + createUserToken({ + name: tokenName.trim(), + scope: tokenScope, + expiresInDays: expiryDays, + }), + onSuccess: (data) => { + setIssuedToken(data); + setTokenName(''); + queryClient.invalidateQueries({ queryKey: ['user-tokens'] }); + }, + }); + const revokeMutation = useMutation({ + mutationFn: (id: string) => revokeUserToken(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }), + }); + + const tokens = tokensQuery.data ?? []; + const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin + ? [ + { value: 'read', label: 'Read - GET requests only' }, + { value: 'write', label: 'Write - read and manage resources' }, + { value: 'admin', label: 'Admin - everything, incl. platform settings' }, + ] + : [ + { value: 'read', label: 'Read - GET requests only' }, + { value: 'write', label: 'Write - read and manage resources' }, + ]; + + return ( +
+
+
+ +
+
+

Personal Access Tokens

+

Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope

+
+
+ +
+
+ setTokenName(e.target.value)} + placeholder="Token name (e.g. laptop cli)" + className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + + + +
+ + {issuedToken ? ( +
+
+ {issuedToken.token} + +
+

+ Shown once - store it now. Use with containr auth login --token or as a Bearer token. +

+
+ ) : null} + {createMutation.isError ? ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'} +

+ ) : null} + + {tokensQuery.isLoading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +

No tokens yet. Create one to connect the CLI or an agent.

+ ) : ( +
+ {tokens.map((token) => ( +
+
+ {token.name} + {token.key_prefix}… + {token.scope} + + {token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'} + {token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''} + +
+ +
+ ))} +
+ )} +
+
+ ); +} + export function SettingsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -1438,6 +1601,9 @@ export function SettingsPage() { )} + {/* Personal Access Tokens */} + + {/* Platform Settings - owner only */} {profileQuery.data?.isAdmin ? : null} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index ec8e49b..04d56fa 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -231,6 +231,153 @@ export interface paths { patch?: never; trace?: never; }; + "/user/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List personal access tokens + * @description Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + tokens?: components["schemas"]["UserToken"][]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + put?: never; + /** + * Create personal access token + * @description Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateUserTokenRequest"]; + }; + }; + responses: { + /** @description Token created; raw value returned once */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserTokenCreated"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin scope requires platform admin */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke personal access token + * @description Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + }; + }; + }; + /** @description Token not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/projects": { parameters: { query?: never; @@ -8476,6 +8623,37 @@ export interface components { /** @description Human-readable label for the token (e.g. node name) */ label?: string; }; + UserToken: { + /** Format: uuid */ + id: string; + name: string; + /** @description First 12 chars of the token, for identification in lists */ + key_prefix: string; + /** @enum {string} */ + scope: "read" | "write" | "admin"; + /** Format: date-time */ + expires_at?: string; + /** Format: date-time */ + last_used_at?: string; + /** Format: date-time */ + created_at: string; + }; + UserTokenCreated: components["schemas"]["UserToken"] & { + /** @description Raw cnp_ token value, returned exactly once at creation */ + token: string; + }; + CreateUserTokenRequest: { + /** @description Human-readable label (e.g. "laptop cli", "ci deploy") */ + name: string; + /** + * @description read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account) + * @default write + * @enum {string} + */ + scope: "read" | "write" | "admin"; + /** @description 0 or omitted = never expires */ + expires_in_days?: number; + }; AgentCapabilities: { container_runtimes?: string[]; supported_architectures?: string[]; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 00ca67a..16968f3 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -52,7 +52,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/OPERATIONS.md", "section": "guides", "title": "Operations", - "markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c `) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n" + "markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c `) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n" }, { "path": "guides/README_BACKEND.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index f98757e..8dc72b8 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -993,6 +993,34 @@ export async function revokeAgentToken(id: string): Promise { await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' }); } +export type UserToken = components['schemas']['UserToken']; +export type UserTokenCreated = components['schemas']['UserTokenCreated']; +export type UserTokenScope = 'read' | 'write' | 'admin'; + +export async function createUserToken(input: { + name: string; + scope?: UserTokenScope; + expiresInDays?: number; +}): Promise { + return requestJson(`/user/tokens`, { + method: 'POST', + body: JSON.stringify({ + name: input.name, + scope: input.scope ?? 'write', + expires_in_days: input.expiresInDays ?? 0, + }), + }); +} + +export async function listUserTokens(): Promise { + const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`); + return payload.tokens ?? []; +} + +export async function revokeUserToken(id: string): Promise { + await requestJson(`/user/tokens/${id}`, { method: 'DELETE' }); +} + export async function createProject(input: CreateProjectInput): Promise { const payload = await requestJson(`/projects`, { method: 'POST', diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6a23059..8869033 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -152,6 +152,97 @@ paths: schema: $ref: '#/components/schemas/Error' + /user/tokens: + get: + tags: + - User + summary: List personal access tokens + description: Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned. + security: + - bearerAuth: [] + responses: + '200': + description: Token list + content: + application/json: + schema: + type: object + properties: + tokens: + type: array + items: + $ref: '#/components/schemas/UserToken' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: + - User + summary: Create personal access token + description: Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserTokenRequest' + responses: + '201': + description: Token created; raw value returned once + content: + application/json: + schema: + $ref: '#/components/schemas/UserTokenCreated' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Admin scope requires platform admin + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /user/tokens/{id}: + delete: + tags: + - User + summary: Revoke personal access token + description: Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Token revoked + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Token not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # Projects /projects: get: @@ -6395,6 +6486,66 @@ components: type: string description: Human-readable label for the token (e.g. node name) + UserToken: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + key_prefix: + type: string + description: First 12 chars of the token, for identification in lists + scope: + type: string + enum: [read, write, admin] + expires_at: + type: string + format: date-time + last_used_at: + type: string + format: date-time + created_at: + type: string + format: date-time + required: + - id + - name + - key_prefix + - scope + - created_at + + UserTokenCreated: + allOf: + - $ref: '#/components/schemas/UserToken' + - type: object + properties: + token: + type: string + description: Raw cnp_ token value, returned exactly once at creation + required: + - token + + CreateUserTokenRequest: + type: object + properties: + name: + type: string + description: Human-readable label (e.g. "laptop cli", "ci deploy") + scope: + type: string + enum: [read, write, admin] + default: write + description: read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account) + expires_in_days: + type: integer + minimum: 0 + maximum: 3650 + description: 0 or omitted = never expires + required: + - name + AgentCapabilities: type: object properties: diff --git a/docs/guides/OPERATIONS.md b/docs/guides/OPERATIONS.md index 4a9be55..011a913 100644 --- a/docs/guides/OPERATIONS.md +++ b/docs/guides/OPERATIONS.md @@ -90,3 +90,30 @@ backend restart. once), copy the prefilled install command, revoke tokens when done. Agents register over `POST /api/agents/register` and heartbeat via `POST /api/agents/heartbeat`. + +## Personal access tokens + +**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the +CLI, MCP server, and scripts — the same privileges as your account, bounded +by scope. The raw token is shown once at creation; only its SHA-256 hash is +stored. + +Scopes: + +- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`. +- `write` — everything except admin routes. +- `admin` — full access; requires a platform admin account, and the token is + still capped at the owner's privileges. + +A token can never outrank its owner: non-admin scopes report +`is_admin = false` even on admin accounts. + +``` +GET /api/v1/user/tokens list (metadata only, never hashes) +POST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once +DELETE /api/v1/user/tokens/:id revoke (owner-scoped) +``` + +Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`. +Revocation takes effect on the next request; `last_used_at` updates on each +authenticated call. From 17e429b86b9f5f25f20ab774dd3a72827d5cd4f9 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 8 Oct 2026 14:31:39 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20ROADMAP2=20=E2=80=94=20dflow=20succ?= =?UTF-8?q?essor=20plan=20and=20phased=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep analysis of dflow (~43 queue processors, ~30 collections, ~180 server actions) mapped against Containr, sectioned into phases A–G. Phase A (agent spine) is in progress — this commit adds PATs. Authored By: TDvorak --- ROADMAP2.md | 396 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 ROADMAP2.md diff --git a/ROADMAP2.md b/ROADMAP2.md new file mode 100644 index 0000000..f96396b --- /dev/null +++ b/ROADMAP2.md @@ -0,0 +1,396 @@ +# Containr — Roadmap 2: The dflow Convergence + +Last verified: **2026-10-08** — deep pass over the full dflow codebase +(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for +preservation) against Containr `main` (`15b5b0e`). + +Goal: make Containr the superior successor — absorb everything worth having +from dflow, then go past it and past Railway. Same power for humans and +agents: the UI is the friendly face, the CLI and MCP are the machine face, +all three sit on the same API. **Branding stays Containr/Vertice** — +white-labeling is an admin option, not an identity change. + +--- + +## 1. What dflow actually is (verified) + +Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every +host is a remote server driven **over SSH** with Dokku auto-installed as the +per-app container manager. Async work = 43 BullMQ queue processors +(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to +deployment records. ~30 collections, ~180 server actions, 11 dashboard +routes per service (general, deployments, logs, environment, domains, +volumes, proxy, scaling, backups, settings). + +### Full capability matrix — dflow → Containr + +Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific) + +#### Deployment pipeline +| dflow capability | Containr status | Action | +|---|---|---| +| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route | +| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream | +| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy | +| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it | +| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service | + +#### Service configuration +| dflow capability | Containr status | Action | +|---|---|---| +| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table | +| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) | +| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` | +| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab | +| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults | +| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale | +| Maintenance mode toggle | ❌ | Traefik middleware label | +| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware | +| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb | +| Service clone (full config copy) | ❌ | `POST /services/:id/clone` | +| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` | +| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow | +| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder | +| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance | + +#### Databases +| dflow capability | Containr status | Action | +|---|---|---| +| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — | +| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint | +| Internal backups + restore (same-type enforced) | ✅ | — | +| Scheduled backups | ✅ cron field | — | +| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper | +| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` | +| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose | +| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — | + +#### Servers / infrastructure +| dflow capability | Containr status | Action | +|---|---|---| +| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again | +| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional | +| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module | +| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules | +| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` | +| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule | +| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists | +| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) | +| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX | +| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning | +| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page | + +#### Platform / org +| dflow capability | Containr status | Action | +|---|---|---| +| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible | +| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join | +| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) | +| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log | +| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar | +| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** | +| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI | +| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend | +| **Impersonate user** | ❌ | Admin support tool | +| In-app docs (content-collections markdown) | ✅ `/docs` | — | +| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables | +| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page | + +#### Agent surface +| dflow capability | Containr status | Action | +|---|---|---| +| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) | +| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** | +| MCP | ❌ | none — same leap | +| Railway migrator script | ❌ | `containr migrate railway` | +| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow | +| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver | +| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) | + +--- + +## 2. The spine: agent-first platform + +*Agent and user on the same level.* Concretely: + +- Every operation reachable by a **personal access token**, not just + sessions. +- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`. +- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/ + Cursor/Codex drive Containr natively. +- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin + clients. + +### Phase A — Agent surface (do this FIRST; everything after inherits it) + +- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`, + `name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes` + (`read`/`write`/`admin`, later resource-scoped), `expires_at`, + `last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored). + `POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts + `Bearer cnp_…`; Settings → Access tokens UI with one-time display + + copyable CLI/MCP config snippets. +- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client: + - `auth login [--token] [--url] [--profile]` (multi-instance profiles). + - Resources: `projects services deploy logs exec variables databases + cron templates nodes scaling ha security gateway notifications + webhooks domains volumes backups admin`. + - `containr up` / `deploy` — cwd→service create-or-update→watch + (the `railway up` equivalent; single most important command). + - `containr link` (bind cwd→service), `containr open`, `containr run`, + `containr shell` (PTY once exec upgrade lands), `completion`. + - Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes + 0/1/2/3/4 (ok/err/not-found/unauthorized/conflict). + - GoReleaser → GH releases + `install.sh`; `version` command. + - Conventions from docsync/envdiff/cix/repolint. +- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the + CLI: `list_projects create_service deploy get_logs set_variable + restart rollback create_database trigger_cron deploy_template exec + get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN` + or shared `~/.containr/config`. `containr mcp serve` + install snippets + for Devin/Claude/Cursor. +- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints; + uniform `{error, code, details}` envelope (audit bare-string returns); + cursor pagination on growing lists; name-or-id lookup everywhere. +- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` + + `llms-full.txt`, MCP tool list generated from OpenAPI where possible. + +**Gate**: an agent holding only a PAT registers a project, deploys from a +git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI +for a human. + +--- + +## 3. Phase B — Deployment pipeline maturity + +dflow's one real architectural edge. Containr deploys are fire-and-forget +goroutines; this fixes correctness *and* unlocks the ops surface. + +- [ ] **Deploy job queue.** Lightweight internal queue (Postgres + `deployment_queue` table + single worker loop — no new infra; Redis + exists but don't add BullMQ-style sprawl). Serialize per service; + optional per-node serialization. Status gains `queued`. +- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` → + engine `CancelDeployment` (exists, unrouted). Queued jobs cancel + cleanly; building jobs cancel context where safe. +- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing + image layers vs clean pull/checkout. Webhook-triggered deploys default + to no-cache (dflow convention — prevents stale-layer surprises). +- [ ] **Queued→building→running status + persisted step logs.** Already + mostly there; add `queued`, stream over existing `/ws`. +- [ ] **Env var apply → redeploy.** Saving variables asks "restart to + apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring + running state. Currently rows update but containers keep stale env — + silent-drift bug class, not just UX. +- [ ] **Encrypt variables at rest.** `environment_variables.value` is + plaintext today (verified). Envelope-encrypt `is_secret` values + (AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow + encrypts every var, we encrypt secrets — migration = transparent + encrypt-on-write + decrypt-on-read. + +## 4. Phase C — Service configuration parity + +- [ ] **Volumes on services** — `services.volumes` jsonb + `[{volume|host_path, container_path, read_only}]`; engine accepts + `VolumeMounts` already. Volumes tab + orphan-volume listing + (`docker volume ls -f dangling`) with delete/attach. +- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default, + cert_type, status, last_checked_at)`. Domains tab: add/remove/ + set-default/DNS-status badge. Keep `services.domain` as derived + default for compat. +- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and + compares vs edge IP → `ok|wrong-target|pending`. +- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels; + no proxy code needed. +- [ ] **Routing overrides** — `services.traefik_labels` jsonb with + allowlist validation (middlewares, headers, redirects). +- [ ] **Builder override** — `services.builder` enum + `auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks` + at `deployments.go:406`. Static builder = build + nginx sidecar. +- [ ] **Resource limits + reserves** — adopt dflow-style explicit units + (`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` → + `NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in + `app_settings`. +- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity + (agent telemetry); warn or block per policy flag. +- [ ] **Service clone** + **move between projects**. +- [ ] **Private registries** — `registry_credentials` + (server/username/secret, encrypted); `docker login` at pull time; + per-service registry link. +- [ ] **Service detail tabs to match dflow's surface**: add Domains, + Volumes, Proxy/access-controls, Backups sections to the service page + (Cron/Previews/Scaling already exist). + +## 5. Phase D — Data layer maturity + +- [ ] **External DB registration** — `provider='managed'|'external'`, + conn fields, `POST /databases/test-connection`, same bind flow. +- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port; + default stays loopback. +- [ ] **Offsite backups** — `backup_targets` (S3-compatible: + endpoint/bucket/keys encrypted); per-DB destination; ship archives via + utility container + s3 SDK; restore-from-S3. +- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate + {target_node_id}`: dump → stream transfer → provision → restore → + re-point bound services. Needs ≥2 nodes or local↔node. +- [ ] **Backup import** — upload archive → new restore point (pairs with + migrators). + +## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates) + +- [ ] **Template = ordered service graph** — `services[]` entries with + `{name, type, source, build, ports, volumes, variables, depends_on}`; + deploy order from dependency refs. +- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref + syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`, + `{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved + graph without deploying (agents love this). +- [ ] **Compose import** — `POST /templates/import/compose` (compose-go + parser → graph), `POST /templates/import/github` (compose file from + repo path). Implements `docs/superpowers/specs/2026-04-14-*`. +- [ ] **Graph deploy pipeline** — creates project + services in + dependency order, resolves vars, triggers builds; defined + partial-failure policy. +- [ ] **Visual template builder** — canvas mode: drop git/image/db + nodes, wire refs, set order, save/publish. Reuses existing canvas + primitives (dflow's `templates/compose` is exactly this). +- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into + real graph templates; screenshots + badges per the old spec. + +**Gate**: paste n8n compose → 3-service project with generated secrets +and wired refs, one click or one CLI call. + +## 7. Phase F — Nodes & infrastructure + +- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` + + UI flow: one SSH session runs agent install (binary + systemd + + enroll token), SSH closed forever. dflow onboarding UX, agent-model + security. +- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/ + zt0 IPs at register; backend stores + displays. Optional + `TAILSCALE_AUTH_KEY` enroll-at-provision later. +- [ ] **Node detail page** — placed services, telemetry history, agent + version + self-upgrade, **drain/cordon**, remove. +- [ ] **Node housekeeping** — agent `prune` command (docker system + prune, bounded) + optional schedule (`setServerAutoCleanup` analog — + disk pressure is the #1 small-VPS killer). +- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists): + pin/spread/affinity, prefer-lowest-usage; deploy engine routes to + `services.node_id` via agent commands (agent already runs containers). +- [ ] **Build-on-node** — agents currently `docker run` prebuilt images + only; extend agent to pull registry images (build stays central) — + private registry creds flow through Phase C. +- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/ + OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module + (dflow's second provider), cloud-init → auto-enroll. +- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) — + auto-domains for services on that node. + +## 8. Phase G — Platform polish + +- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers, + enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`; + events: `deployment.* service.* database.* security.* agent.*`. +- [ ] **Activity feed** — enrich `audit_logs` with `severity category + icon label metadata`; per-project timeline + global feed; TTL job. +- [ ] **Banners** — `banners(title, body, level, scope, active)`; top + bar in UI + surfaced to CLI on `auth status`. +- [ ] **White-label** — `app_settings.branding`: product name, logos, + favicon, accent override, docs/support links. `GET /api/v1/branding` + public + cached. **Containr/Vertice remains default;** this is for + self-hosters who rebrand their instance. +- [ ] **Operations page** — unified view of running/queued/failed jobs + (deploys, builds, backups, cron, scans, agent commands) — dflow's + bubble panel as a real page; flush/stuck-job tools once queue exists. +- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`); + `nodes/:id/terminal` via agent (dflow's wetty without the extra + container). +- [ ] **Team invites** — `POST /invites` → signed link → join flow; + replaces manual-only user creation. +- [ ] **Impersonate user** — admin support tool, audit-logged. +- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest` + returns manifest + redirect; instance self-provisions its App + (dflow pattern — removes the env-setup barrier). +- [ ] **fork-sync trigger** — accept repo-sync events on webhook + receiver. +- [ ] **Dynamic filter framework** — port dflow's `filter.utils` + pattern (typed filter configs → URL state → client filtering) for + audit logs, deployments, notifications tables. +- [ ] **Notification producers round-out** — backups, agent offline, + upgrade available, security findings. +- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed + forward-compatible now. + +## 9. Phase H — Migration & ecosystem + +- [ ] **Railway migrator** — `containr migrate railway --token --project + --dry-run`: Railway GraphQL → services/vars/domains → recreate via + PAT. Landing-page pitch: "off Railway in one command." +- [ ] **dflow importer** — `containr migrate dflow` via Payload REST + export or Mongo dump: servers→nodes, projects, services(+vars/ + domains/volumes), databases, templates. Onboards dflow refugees. +- [ ] **`containr import compose`** — Phase-E parser straight to a + project (no template intermediate). +- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL` + fetch/publish; bundled catalog works offline (dflow's self-contained + principle — nothing phones home). + +## 10. Carried-over debt (ROADMAP.md items still open) + +- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit → + `gateway/proxy.go` → metrics. Management API exists; proxy unwired. +- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL + (bookkeeping only now). Pairs with clone + multi-domain. +- [ ] **Environments** — env switcher, per-env vars/domains, promote. +- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`. +- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ + webhook table tests, Playwright e2e golden path. +- [ ] **First-run wizard** — admin → GitHub App (manifest flow from + Phase G makes this self-serve) → Tunnel → done. +- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release. + +## 11. Deliberately not imported from dflow + +- Dokku-over-SSH runtime — our Docker API + agent model is strictly + better. +- Per-server-named BullMQ queues — our queue stays a single ordered + table. +- Payload/Mongo/Next.js stack, multi-tenant plugin machinery. +- Ansible playbook runner — arbitrary code-on-node is a security + surface; SSH bootstrap + agent commands cover the real needs. + Revisit only with a concrete demand. +- `isAdmin`-only collection access — our owner/admin/public model is + richer already. +- Netdata install flow — our agent telemetry + optional Beszel covers + monitoring; don't ship a third dashboard stack. + +## 12. Recommended order + +``` +A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth) + │ │ + ▼ ▼ + CLI/MCP dogfood every feature E (templates v2) + │ + F (nodes/infra) ──► H (migrators) + │ + G (polish) + debt +``` + +Why this order: PATs+CLI+MCP first = every later feature ships +user-and-agent-visible at once. The queue (B) is prerequisite for +the ops surface and correct concurrency. Templates v2 needs volumes, +multi-domain, clone. Migrators last — they're launch marketing, not +foundation. + +## 13. Design rules + +- **API is the contract.** UI, CLI, MCP are thin clients over + `openapi.yaml`. A feature isn't done until all three reach it. +- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform + pagination, name-or-id addressing. +- **Same powers, same position.** Nothing user-facing that isn't + API-facing; agent-visible state (tokens, jobs) visible to users too. +- **dflow is a donor, not a dependency.** Port semantics, not code. +- **Containr branding is the default**; white-label is instance config. +- **Self-hosted first** — no required external service, no phone-home.