diff --git a/.env.example b/.env.example
index ab5fbe7..233ce5f 100644
--- a/.env.example
+++ b/.env.example
@@ -64,6 +64,12 @@ BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
# automatically, so this only needs entries for cross-origin callers.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
+
+# Platform owner bootstrap — creates/promotes the admin account on startup.
+# Keep the real values in a local untracked file, never in committed env files.
+ADMIN_EMAIL=info@tdvorak.dev
+ADMIN_PASSWORD=
+ADMIN_NAME=Platform Admin
# Optional explicit auth DB settings (recommended when password contains URL special chars)
DB_HOST=postgres
DB_PORT=5432
diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go
index 7c10fcb..46304fd 100644
--- a/app/backend/cmd/server/main.go
+++ b/app/backend/cmd/server/main.go
@@ -86,6 +86,9 @@ func main() {
}()
}
+ // Provision the platform owner account (idempotent, non-fatal).
+ api.EnsureAdminAccount(context.Background(), db, cfg)
+
// Setup Gin router
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
diff --git a/app/backend/internal/api/admin.go b/app/backend/internal/api/admin.go
new file mode 100644
index 0000000..51cebf2
--- /dev/null
+++ b/app/backend/internal/api/admin.go
@@ -0,0 +1,295 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "containr/internal/config"
+ "containr/internal/database"
+ "containr/internal/database/sqlcdb"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// ---------------------------------------------------------------------------
+// Admin account bootstrap
+// ---------------------------------------------------------------------------
+
+// EnsureAdminAccount provisions the platform owner account from
+// ADMIN_EMAIL/ADMIN_PASSWORD (optional ADMIN_NAME). It is idempotent: existing
+// users are simply promoted to admin. Both the local users row (legacy JWT
+// login) and the Better Auth account (cookie sessions) are created so either
+// sign-in path works. Failures are logged, never fatal — the platform must
+// boot even when auth is misconfigured.
+func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config) {
+ email := strings.ToLower(strings.TrimSpace(cfg.AdminEmail))
+ password := cfg.AdminPassword
+ if email == "" || password == "" {
+ return
+ }
+ if db == nil || db.DB == nil {
+ log.Println("Admin bootstrap skipped: database unavailable")
+ return
+ }
+ name := strings.TrimSpace(cfg.AdminName)
+ if name == "" {
+ name = "Platform Admin"
+ }
+
+ // Local users row (owns resources, is_admin flag, legacy login hash).
+ hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ log.Printf("Admin bootstrap failed: %v", err)
+ return
+ }
+
+ var userID string
+ err = db.QueryRowContext(ctx, `
+ INSERT INTO users (email, password_hash, name, is_admin)
+ VALUES ($1, $2, $3, true)
+ ON CONFLICT (email) DO UPDATE
+ SET is_admin = true, updated_at = NOW()
+ RETURNING id
+ `, email, string(hashed), name).Scan(&userID)
+ if err != nil {
+ log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
+ return
+ }
+
+ // Better Auth account (cookie-session sign-in). Duplicate sign-up returns
+ // an error which we treat as "already provisioned".
+ if err := ensureBetterAuthUser(ctx, name, email, password); err != nil {
+ if errors.Is(err, errBetterAuthUserExists) {
+ log.Printf("Admin account %s already exists in auth service; ensured admin flag", email)
+ } else {
+ log.Printf("Warning: admin bootstrap could not create auth user for %s: %v", email, err)
+ }
+ return
+ }
+ log.Printf("Admin account provisioned: %s", email)
+}
+
+// ensureBetterAuthUser calls the Better Auth sign-up endpoint directly. Unlike
+// createBetterAuthUser it runs outside a request context.
+func ensureBetterAuthUser(ctx context.Context, name, email, password string) error {
+ endpoint, err := betterAuthSignUpURL()
+ if err != nil {
+ return err
+ }
+
+ body, err := json.Marshal(gin.H{"name": name, "email": email, "password": password})
+ if err != nil {
+ return err
+ }
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Accept", "application/json")
+ if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
+ request.Header.Set("X-Containr-Auth-Internal", token)
+ }
+
+ response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
+ return nil
+ }
+
+ var payload map[string]interface{}
+ _ = json.NewDecoder(response.Body).Decode(&payload)
+ message := ""
+ if raw, ok := payload["message"].(string); ok {
+ message = strings.ToLower(raw)
+ } else if raw, ok := payload["error"].(string); ok {
+ message = strings.ToLower(raw)
+ }
+ if strings.Contains(message, "exist") || strings.Contains(message, "already") {
+ return errBetterAuthUserExists
+ }
+ if message == "" {
+ message = http.StatusText(response.StatusCode)
+ }
+ return errors.New(message)
+}
+
+// ---------------------------------------------------------------------------
+// Admin API
+// ---------------------------------------------------------------------------
+
+type adminUserRow struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ AvatarURL string `json:"avatar_url"`
+ IsAdmin bool `json:"is_admin"`
+ CreatedAt string `json:"created_at"`
+}
+
+func handleAdminOverview(c *gin.Context) {
+ db := c.MustGet("db").(*database.DB)
+ queries := sqlcdb.New(db.DB)
+ ctx := c.Request.Context()
+
+ count := func(query string) int {
+ var n int
+ if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil {
+ return 0
+ }
+ return n
+ }
+
+ pendingRows, err := queries.ListPendingProjects(ctx)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list pending projects"})
+ return
+ }
+ pending := make([]Project, 0, len(pendingRows))
+ for _, row := range pendingRows {
+ pending = append(pending, mapSQLCProject(row))
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "stats": gin.H{
+ "users": count(`SELECT COUNT(*) FROM users`),
+ "projects": count(`SELECT COUNT(*) FROM projects`),
+ "pending_projects": count(`SELECT COUNT(*) FROM projects WHERE is_approved = false`),
+ "services": count(`SELECT COUNT(*) FROM services`),
+ "running_services": count(`SELECT COUNT(*) FROM services WHERE status = 'running'`),
+ "databases": count(`SELECT COUNT(*) FROM database_services`),
+ "agents": count(`SELECT COUNT(*) FROM node_agents`),
+ "templates": count(`SELECT COUNT(*) FROM service_templates`),
+ "user_templates": count(`SELECT COUNT(*) FROM service_templates WHERE is_official = false`),
+ "cron_jobs": count(`SELECT COUNT(*) FROM cron_jobs`),
+ "deployments_total": count(`SELECT COUNT(*) FROM deployments`),
+ },
+ "pending_projects": pending,
+ })
+}
+
+func handleAdminListUsers(c *gin.Context) {
+ db := c.MustGet("db").(*database.DB)
+
+ rows, err := db.QueryContext(c.Request.Context(), `
+ SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
+ FROM users
+ ORDER BY created_at ASC
+ `)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
+ return
+ }
+ defer rows.Close()
+
+ users := make([]adminUserRow, 0)
+ for rows.Next() {
+ var u adminUserRow
+ if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
+ continue
+ }
+ users = append(users, u)
+ }
+ c.JSON(http.StatusOK, gin.H{"users": users})
+}
+
+func handleAdminSetUserAdmin(c *gin.Context) {
+ db := c.MustGet("db").(*database.DB)
+ targetID := strings.TrimSpace(c.Param("id"))
+ if _, err := uuid.Parse(targetID); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
+ return
+ }
+
+ var req struct {
+ IsAdmin bool `json:"is_admin"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ callerID := optionalUserID(c)
+ if callerID == targetID && !req.IsAdmin {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot revoke your own admin access"})
+ return
+ }
+
+ result, err := db.ExecContext(c.Request.Context(),
+ `UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
+ req.IsAdmin, targetID,
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
+ return
+ }
+ if n, _ := result.RowsAffected(); n == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ LogAudit(callerID, "user", targetID, "set_admin", map[string]interface{}{"is_admin": req.IsAdmin})
+ c.JSON(http.StatusOK, gin.H{"message": "User updated"})
+}
+
+func handleAdminSetProjectApproval(c *gin.Context) {
+ db := c.MustGet("db").(*database.DB)
+ queries := sqlcdb.New(db.DB)
+
+ projectID, ok := parseProjectIDParam(c)
+ if !ok {
+ return
+ }
+
+ var req struct {
+ IsApproved bool `json:"is_approved"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ rows, err := queries.SetProjectApproved(c.Request.Context(), sqlcdb.SetProjectApprovedParams{
+ IsApproved: req.IsApproved,
+ ProjectID: projectID,
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
+ return
+ }
+ if rows == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
+ return
+ }
+
+ action := "unapprove"
+ if req.IsApproved {
+ action = "approve"
+ }
+ LogAudit(optionalUserID(c), "project", projectID.String(), action, nil)
+
+ row, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
+ ProjectID: projectID,
+ UserID: optionalUserUUID(c),
+ IsAdmin: true,
+ })
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"message": "Project updated"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"project": mapSQLCProject(row)})
+}
diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go
index ac82b79..fbe58b9 100644
--- a/app/backend/internal/api/agents.go
+++ b/app/backend/internal/api/agents.go
@@ -1217,8 +1217,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
}
}
-// SetupRoutes registers authenticated dashboard/control agent routes.
-func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
+// SetupAdminRoutes registers dashboard/control agent routes. Command
+// history and container details can leak sensitive output, so these stay
+// admin-only rather than joining the public read group.
+func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
tokens := router.Group("/agent-tokens")
{
tokens.POST("", h.CreateAgentToken)
diff --git a/app/backend/internal/api/context_user.go b/app/backend/internal/api/context_user.go
index ba67391..3a162fd 100644
--- a/app/backend/internal/api/context_user.go
+++ b/app/backend/internal/api/context_user.go
@@ -3,7 +3,10 @@ package api
import (
"net/http"
+ "containr/internal/database"
+
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
)
func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
@@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
return userID, true
}
+
+// optionalUserID returns the resolved user id or "" for anonymous callers on
+// OptionalAuth routes. Never writes a response.
+func optionalUserID(c *gin.Context) string {
+ userIDValue, exists := c.Get("user_id")
+ if !exists {
+ return ""
+ }
+ userID, _ := userIDValue.(string)
+ return userID
+}
+
+// optionalUserUUID is the uuid variant of optionalUserID; anonymous callers
+// get uuid.Nil, which matches no owner_id and therefore only sees approved
+// public resources.
+func optionalUserUUID(c *gin.Context) uuid.UUID {
+ userID := optionalUserID(c)
+ parsed, err := uuid.Parse(userID)
+ if err != nil {
+ return uuid.Nil
+ }
+ return parsed
+}
+
+// contextIsAdmin reports whether the resolved caller is a platform admin.
+func contextIsAdmin(c *gin.Context) bool {
+ return c.GetBool("is_admin")
+}
+
+// projectReadAccess reports (exists, allowedToRead). Approved projects are
+// public; unapproved ones are visible to the owner, project members, and
+// platform admins only.
+func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
+ var isApproved bool
+ var ownerID string
+ err := db.QueryRow(
+ `SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
+ projectID,
+ ).Scan(&isApproved, &ownerID)
+ if err != nil {
+ return false, false
+ }
+ if isApproved || contextIsAdmin(c) {
+ return true, true
+ }
+ userID := optionalUserID(c)
+ if userID == "" {
+ return true, false
+ }
+ if ownerID == userID {
+ return true, true
+ }
+ var member bool
+ if err := db.QueryRow(
+ `SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
+ projectID, userID,
+ ).Scan(&member); err == nil && member {
+ return true, true
+ }
+ return true, false
+}
+
+// projectManageAccess reports (exists, allowedToOperate). Unlike
+// projectReadAccess, approval does NOT grant access — only the owner,
+// project members, and platform admins may reach secret-adjacent data
+// such as variables and runtime logs.
+func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
+ var ownerID string
+ err := db.QueryRow(
+ `SELECT owner_id::text FROM projects WHERE id = $1`,
+ projectID,
+ ).Scan(&ownerID)
+ if err != nil {
+ return false, false
+ }
+ if contextIsAdmin(c) {
+ return true, true
+ }
+ userID := optionalUserID(c)
+ if userID == "" {
+ return true, false
+ }
+ if ownerID == userID {
+ return true, true
+ }
+ var member bool
+ if err := db.QueryRow(
+ `SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
+ projectID, userID,
+ ).Scan(&member); err == nil && member {
+ return true, true
+ }
+ return true, false
+}
+
+// serviceProjectID resolves the project that owns a service.
+func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
+ var projectID uuid.UUID
+ err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
+ if err != nil {
+ return uuid.Nil, false
+ }
+ return projectID, true
+}
diff --git a/app/backend/internal/api/cron.go b/app/backend/internal/api/cron.go
index 7f5f7f6..0b15f81 100644
--- a/app/backend/internal/api/cron.go
+++ b/app/backend/internal/api/cron.go
@@ -65,19 +65,34 @@ type UpdateCronJobRequest struct {
Retention int `json:"retention"`
}
+// cronJobProjectID resolves the project a cron job belongs to.
+func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
+ var projectID uuid.UUID
+ err := db.QueryRow(
+ `SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
+ jobID,
+ ).Scan(&projectID)
+ if err != nil {
+ return uuid.Nil, false
+ }
+ return projectID, true
+}
+
func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
- userID := c.MustGet("user_id").(string)
+ userID := optionalUserUUID(c)
+ isAdmin := contextIsAdmin(c)
projectID := c.Query("project_id")
serviceID := c.Query("service_id")
- query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
- cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
+ query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
+ cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
- WHERE p.owner_id = $1`
- args := []interface{}{userID}
+ 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))`
+ args := []interface{}{userID, isAdmin}
if projectID != "" {
args = append(args, projectID)
@@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) {
req.ServiceID,
).Scan(&ownerCheck)
- if err != nil || ownerCheck != userID {
+ if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) {
func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
- userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var job CronJob
- var ownerCheck string
err := db.QueryRow(
- `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
- cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
- cj.retention, cj.created_at, cj.updated_at, p.owner_id
+ `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
+ cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
+ cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
- JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
- &job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
+ &job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
- if ownerCheck != userID {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ projectID, found := cronJobProjectID(db, jobID)
+ if !found {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
+ return
+ }
+ if _, allowed := projectReadAccess(c, db, projectID); !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
- if err != nil || ownerCheck != userID {
+ if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
- if err != nil || ownerCheck != userID {
+ if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) {
func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
- userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
- var ownerCheck string
- err := db.QueryRow(
- `SELECT p.owner_id FROM cron_jobs cj
- JOIN projects p ON cj.project_id = p.id
- WHERE cj.id = $1`,
- jobID,
- ).Scan(&ownerCheck)
-
- if err != nil || ownerCheck != userID {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ projectID, found := cronJobProjectID(db, jobID)
+ if !found {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
+ return
+ }
+ if _, allowed := projectReadAccess(c, db, projectID); !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) {
jobID,
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
- if err != nil || ownerCheck != userID {
+ if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go
index 1f0ea0b..0fd2e85 100644
--- a/app/backend/internal/api/databases.go
+++ b/app/backend/internal/api/databases.go
@@ -41,6 +41,7 @@ const (
// DatabaseService represents a managed database service
type DatabaseService struct {
ID string `json:"id" db:"id"`
+ OwnerID string `json:"-" db:"user_id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
Status string `json:"status" db:"status"` // running, stopped, building, error
@@ -168,13 +169,14 @@ func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandle
}
}
-// GetDatabases returns all database services for a user
+// GetDatabases returns database services. The platform is public-read: every
+// caller sees the fleet, but connection URLs (which may embed credentials)
+// are only exposed to admins.
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
- userID, ok := requireAuthenticatedUserID(c)
- if !ok {
- return
- }
- rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID)
+ userID := optionalUserID(c)
+ isAdmin := contextIsAdmin(c)
+
+ rows, err := h.queries.ListAllDatabaseServices(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
return
@@ -182,17 +184,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
databases := make([]DatabaseService, 0, len(rows))
for _, row := range rows {
- db := mapDatabaseServiceListRow(row)
+ db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
- if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
- db.Backups = backupConfig
+ privileged := isAdmin || (userID != "" && db.OwnerID == userID)
+ if privileged {
+ if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
+ db.Backups = backupConfig
+ } else {
+ db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
+ }
+ db.Settings = h.generateMockSettings()
+ db.ConnectionURL = h.resolveConnectionURL(db)
} else {
- db.Backups = h.generateMockBackupConfig()
+ db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
+ db.ConnectionURL = ""
}
- db.Settings = h.generateMockSettings()
- db.ConnectionURL = h.resolveConnectionURL(db)
databases = append(databases, db)
}
@@ -202,15 +210,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
// GetDatabase returns a specific database service
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
- userID, ok := requireAuthenticatedUserID(c)
- if !ok {
- return
- }
+ userID := optionalUserID(c)
+ isAdmin := contextIsAdmin(c)
+
databaseID := c.Param("id")
- row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
- ID: databaseID,
- UserID: userID,
- })
+ row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
@@ -219,21 +223,40 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
- db := mapDatabaseServiceGetRow(row)
+ db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
- if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
- db.Backups = backupConfig
+ privileged := isAdmin || (userID != "" && db.OwnerID == userID)
+ if privileged {
+ if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
+ db.Backups = backupConfig
+ } else {
+ db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
+ }
+ db.Settings = h.generateMockSettings()
+ db.ConnectionURL = h.resolveConnectionURL(db)
} else {
- db.Backups = h.generateMockBackupConfig()
+ db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
+ db.ConnectionURL = ""
}
- db.Settings = h.generateMockSettings()
- db.ConnectionURL = h.resolveConnectionURL(db)
c.JSON(http.StatusOK, db)
}
+// effectiveUserID returns the user scope for row-level queries. Admins act
+// on behalf of the row's owner so management works across accounts.
+func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string {
+ if !contextIsAdmin(c) || databaseID == "" {
+ return userID
+ }
+ owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID)
+ if err == nil && owner != "" {
+ return owner
+ }
+ return userID
+}
+
// CreateDatabase creates a new database service
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
@@ -357,6 +380,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
return
}
databaseID := c.Param("id")
+ userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -440,6 +464,7 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
return
}
databaseID := c.Param("id")
+ userID = h.effectiveUserID(c, userID, databaseID)
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
ID: databaseID,
@@ -481,6 +506,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
return
}
databaseID := c.Param("id")
+ userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -574,6 +600,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
+ userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
@@ -623,6 +650,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
+ userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseRestoreRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -684,6 +712,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
+ userID = h.effectiveUserID(c, userID, databaseID)
backupID := c.Param("bid")
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
@@ -904,35 +933,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
}, nil
}
-func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
- return DatabaseBackupConfig{
- Enabled: true,
- LastBackup: &time.Time{},
- Retention: 30,
- NextBackup: &time.Time{},
- Backups: []DatabaseBackup{
- {
- ID: "backup_1",
- CreatedAt: time.Now().Add(-6 * time.Hour),
- Size: "245 MB",
- Status: "completed",
- },
- {
- ID: "backup_2",
- CreatedAt: time.Now().Add(-24 * time.Hour),
- Size: "238 MB",
- Status: "completed",
- },
- {
- ID: "backup_3",
- CreatedAt: time.Now().Add(-48 * time.Hour),
- Size: "241 MB",
- Status: "completed",
- },
- },
- }
-}
-
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
return DatabaseSettings{
MaxConnections: 100,
@@ -1038,26 +1038,10 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
}
}
-func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
- return DatabaseService{
- ID: row.ID,
- Name: row.Name,
- Type: row.Type,
- Status: row.Status,
- Version: row.Version,
- Plan: row.Plan,
- Region: row.Region,
- BackupSchedule: databaseNullString(row.BackupSchedule),
- NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
- ConnectionURL: databaseNullString(row.ConnectionUrl),
- CreatedAt: databaseNullTime(row.CreatedAt),
- UpdatedAt: databaseNullTime(row.UpdatedAt),
- }
-}
-
-func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
+func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
return DatabaseService{
ID: row.ID,
+ OwnerID: row.UserID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go
index d63a372..bbb4c5a 100644
--- a/app/backend/internal/api/deployments.go
+++ b/app/backend/internal/api/deployments.go
@@ -64,36 +64,22 @@ func handleGetDeployments(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
+ projectID, found := serviceProjectID(db.(*database.DB), serviceID)
+ if !found {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
-
- var ownerCheck string
- err = db.(*database.DB).QueryRow(
- `SELECT p.owner_id FROM services s
- JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1`,
- serviceID,
- ).Scan(&ownerCheck)
-
- if err != nil {
+ if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
- if ownerCheck != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
- return
- }
-
rows, err := db.(*database.DB).Query(
- `SELECT id, service_id, commit_hash, status, image_name, image_tag,
+ `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
+ FROM deployments
+ WHERE service_id = $1
+ ORDER BY created_at DESC
LIMIT 50`,
serviceID,
)
@@ -143,11 +129,8 @@ func handleGetRecentDeployments(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
+ userID := optionalUserUUID(c)
+ isAdmin := contextIsAdmin(c)
limit := 10
if v := c.Query("limit"); v != "" {
@@ -162,10 +145,11 @@ func handleGetRecentDeployments(c *gin.Context) {
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
- WHERE p.owner_id = $1
+ 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 $2`,
- userID.(string), limit,
+ LIMIT $3`,
+ userID, isAdmin, limit,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -557,18 +541,12 @@ func handleGetDeployment(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
var d DeploymentModel
- var ownerCheck string
+ var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
- `SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
- d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
- d.created_at, d.updated_at, p.owner_id
+ `SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
+ d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
+ d.created_at, d.updated_at, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
@@ -577,7 +555,7 @@ func handleGetDeployment(c *gin.Context) {
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
- &d.CreatedAt, &d.UpdatedAt, &ownerCheck,
+ &d.CreatedAt, &d.UpdatedAt, &projectID,
)
if err != nil {
@@ -585,8 +563,8 @@ func handleGetDeployment(c *gin.Context) {
return
}
- if ownerCheck != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
@@ -639,7 +617,7 @@ func handleRollbackDeployment(c *gin.Context) {
return
}
- if ownerCheck != userID.(string) {
+ if ownerCheck != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
diff --git a/app/backend/internal/api/exec.go b/app/backend/internal/api/exec.go
index 09a125d..580135c 100644
--- a/app/backend/internal/api/exec.go
+++ b/app/backend/internal/api/exec.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
"containr/internal/database"
"containr/internal/docker"
@@ -17,7 +18,6 @@ import (
// commands run as the container's default user with a 30s ceiling.
func handleExecInService(c *gin.Context) {
serviceID := c.Param("id")
- userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req struct {
@@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) {
return
}
- var ownerID string
- err := db.QueryRow(
- `SELECT p.owner_id FROM services s
- JOIN projects p ON p.id = s.project_id
- WHERE s.id = $1`,
- serviceID,
- ).Scan(&ownerID)
- if err != nil || ownerID != userID {
+ serviceUUID, err := uuid.Parse(serviceID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
+ return
+ }
+ projectID, found := serviceProjectID(db, serviceUUID)
+ if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
+ if _, allowed := projectManageAccess(c, db, projectID); !allowed {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ return
+ }
dockerClient, _ := c.Get("docker_client")
dockerCli, _ := dockerClient.(*docker.Client)
diff --git a/app/backend/internal/api/ha.go b/app/backend/internal/api/ha.go
index f74fb28..1b67b93 100644
--- a/app/backend/internal/api/ha.go
+++ b/app/backend/internal/api/ha.go
@@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
}
}
-// RegisterRoutes registers HA routes
-func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
+// RegisterReadRoutes registers public HA read endpoints.
+func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.GET("/status", h.GetHAStatus)
+
+ // Failover policies
+ ha.GET("/failover/policies", h.GetFailoverPolicies)
+ ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
+
+ // Health checks
+ ha.GET("/health/checks", h.GetHealthChecks)
+ ha.GET("/health/checks/:checkId", h.GetHealthCheck)
+ ha.GET("/health/results", h.GetHealthResults)
+
+ // Alerts
+ ha.GET("/alerts/rules", h.GetAlertRules)
+ ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
+ ha.GET("/alerts/active", h.GetActiveAlerts)
+
+ // Notifiers
+ ha.GET("/notifiers", h.GetNotifiers)
+ ha.GET("/notifiers/:notifierId", h.GetNotifier)
+ }
+}
+
+// RegisterAdminRoutes registers HA mutation endpoints.
+func (h *HAManager) RegisterAdminRoutes(router *gin.RouterGroup) {
+ ha := router.Group("/ha")
+ {
ha.POST("/enable", h.EnableHA)
ha.POST("/disable", h.DisableHA)
ha.POST("/failover", h.TriggerFailover)
// Failover policies
- ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.POST("/failover/policies", h.SetFailoverPolicy)
- ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
// Health checks
- ha.GET("/health/checks", h.GetHealthChecks)
ha.POST("/health/checks", h.AddHealthCheck)
- ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
- ha.GET("/health/results", h.GetHealthResults)
// Alerts
- ha.GET("/alerts/rules", h.GetAlertRules)
ha.POST("/alerts/rules", h.AddAlertRule)
- ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
- ha.GET("/alerts/active", h.GetActiveAlerts)
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
// Notifiers
- ha.GET("/notifiers", h.GetNotifiers)
ha.POST("/notifiers", h.AddNotifier)
- ha.GET("/notifiers/:notifierId", h.GetNotifier)
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
}
}
diff --git a/app/backend/internal/api/logs.go b/app/backend/internal/api/logs.go
index 6b2d587..c533922 100644
--- a/app/backend/internal/api/logs.go
+++ b/app/backend/internal/api/logs.go
@@ -37,27 +37,13 @@ func handleGetLogs(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- var ownerCheck string
- err = db.(*database.DB).QueryRow(
- `SELECT p.owner_id FROM services s
- JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1`,
- serviceID,
- ).Scan(&ownerCheck)
-
- if err != nil {
+ projectID, found := serviceProjectID(db.(*database.DB), serviceID)
+ if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
-
- if ownerCheck != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
@@ -209,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
var buildLog, runtimeLog string
- var ownerCheck string
+ var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
- `SELECT d.build_log, d.runtime_log, p.owner_id
+ `SELECT d.build_log, d.runtime_log, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
- ).Scan(&buildLog, &runtimeLog, &ownerCheck)
+ ).Scan(&buildLog, &runtimeLog, &projectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
- if ownerCheck != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
diff --git a/app/backend/internal/api/metrics.go b/app/backend/internal/api/metrics.go
index 4b8fd65..e2c9813 100644
--- a/app/backend/internal/api/metrics.go
+++ b/app/backend/internal/api/metrics.go
@@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- var ownerID string
+ var projectID uuid.UUID
var status string
err = db.QueryRow(
- `SELECT p.owner_id, COALESCE(s.status, '')
- FROM services s JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1`,
+ `SELECT s.project_id, COALESCE(s.status, '')
+ FROM services s WHERE s.id = $1`,
serviceID,
- ).Scan(&ownerID, &status)
+ ).Scan(&projectID, &status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
- if ownerID != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ if _, allowed := projectReadAccess(c, db, projectID); !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
diff --git a/app/backend/internal/api/preview_environments.go b/app/backend/internal/api/preview_environments.go
index e897255..8d4e325 100644
--- a/app/backend/internal/api/preview_environments.go
+++ b/app/backend/internal/api/preview_environments.go
@@ -69,31 +69,13 @@ func handleGetPreviewEnvironments(c *gin.Context) {
return
}
- // Check if project exists and user has access
- var project Project
- err = db.(*database.DB).QueryRow(
- "SELECT id, name, owner_id FROM projects WHERE id = $1",
- projectID,
- ).Scan(&project.ID, &project.Name, &project.OwnerID)
-
- if err != nil {
+ // Approved projects are public; unapproved ones need owner/member/admin.
+ projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
+ if !projectExists || !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
- // Get user ID from JWT token (set by auth middleware)
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- // Check if user owns the project
- if project.OwnerID != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
- return
- }
-
// Get preview environments for the project with service info
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
@@ -193,7 +175,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
- if project.OwnerID != userID.(string) {
+ if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -332,27 +314,22 @@ func handleGetPreviewEnvironment(c *gin.Context) {
return
}
- // Get user ID from JWT token
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- // Get preview environment with project ownership check
+ // Public when the parent project is approved; otherwise owner/member/admin.
+ userID := optionalUserUUID(c)
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
err = db.(*database.DB).QueryRow(
- `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
+ `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
- WHERE pe.id = $1 AND p.owner_id = $2`,
- envID, userID,
+ WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
+ OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
+ envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
@@ -414,8 +391,8 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
- WHERE pe.id = $1 AND p.owner_id = $2`,
- envID, userID,
+ WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
+ envID, userID, contextIsAdmin(c),
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
@@ -498,7 +475,7 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
- if projectOwnerID != userID.(string) {
+ if projectOwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -568,8 +545,8 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
- WHERE pe.id = $1 AND p.owner_id = $2`,
- envID, userID,
+ WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
+ envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
)
@@ -665,8 +642,8 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
- WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
- userID,
+ WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
+ userID, contextIsAdmin(c),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
diff --git a/app/backend/internal/api/production_behavior_test.go b/app/backend/internal/api/production_behavior_test.go
index b05accf..36eca98 100644
--- a/app/backend/internal/api/production_behavior_test.go
+++ b/app/backend/internal/api/production_behavior_test.go
@@ -6,8 +6,6 @@ import (
"net/http/httptest"
"testing"
- "containr/internal/database"
-
"github.com/gin-gonic/gin"
)
@@ -89,7 +87,7 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
}
}
-func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
+func TestRecentDeploymentsAllowAnonymousRead(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/v1/deployments", handleGetRecentDeployments)
@@ -98,20 +96,9 @@ func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
- // No db in context → dependency failure surfaces before auth.
+ // Deployments are publicly readable; with no db in context the handler
+ // fails at the dependency boundary, never at an auth check.
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
}
-
- // With db present but no user context → 401.
- router2 := gin.New()
- router2.GET("/api/v1/deployments", func(c *gin.Context) {
- c.Set("db", &database.DB{})
- handleGetRecentDeployments(c)
- })
- rec2 := httptest.NewRecorder()
- router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil))
- if rec2.Code != http.StatusUnauthorized {
- t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code)
- }
}
diff --git a/app/backend/internal/api/projects.go b/app/backend/internal/api/projects.go
index 6665f09..f5eebc8 100644
--- a/app/backend/internal/api/projects.go
+++ b/app/backend/internal/api/projects.go
@@ -21,6 +21,7 @@ type Project struct {
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
+ IsApproved bool `json:"is_approved"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -48,10 +49,8 @@ type UpdateProjectRequest struct {
}
func handleGetProjects(c *gin.Context) {
- userID, ok := requireAuthenticatedUserUUID(c)
- if !ok {
- return
- }
+ userID := optionalUserUUID(c)
+ isAdmin := contextIsAdmin(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
@@ -75,6 +74,7 @@ func handleGetProjects(c *gin.Context) {
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
UserID: userID,
+ IsAdmin: isAdmin,
Search: searchParam,
LimitCount: int32(limit),
OffsetCount: int32(offset),
@@ -90,8 +90,9 @@ func handleGetProjects(c *gin.Context) {
}
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
- UserID: userID,
- Search: searchParam,
+ UserID: userID,
+ IsAdmin: isAdmin,
+ Search: searchParam,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
@@ -171,10 +172,7 @@ func handleCreateProject(c *gin.Context) {
}
func handleGetProject(c *gin.Context) {
- userID, ok := requireAuthenticatedUserUUID(c)
- if !ok {
- return
- }
+ userID := optionalUserUUID(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
@@ -187,6 +185,7 @@ func handleGetProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
+ IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -217,6 +216,7 @@ func handleUpdateProject(c *gin.Context) {
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
ProjectID: projectID,
UserID: userID,
+ IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
@@ -270,6 +270,7 @@ func handleUpdateProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
+ IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -307,7 +308,7 @@ func handleDeleteProject(c *gin.Context) {
return
}
- if ownerID != userID {
+ if ownerID != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
return
}
@@ -361,7 +362,7 @@ func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
result := ProjectWithStats{
- Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.CreatedAt, row.UpdatedAt),
+ Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
Stats: ProjectStats{
ServiceCount: int(row.ServiceCount),
DeploymentCount: int(row.DeploymentCount),
@@ -373,15 +374,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi
}
func mapSQLCProject(project sqlcdb.Project) Project {
- return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.CreatedAt, project.UpdatedAt)
+ return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
}
-func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, createdAt, updatedAt sql.NullTime) Project {
+func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
return Project{
ID: id.String(),
Name: name,
Description: nullableStringValue(description),
OwnerID: ownerID.String(),
+ IsApproved: isApproved,
CreatedAt: nullableTimestampString(createdAt),
UpdatedAt: nullableTimestampString(updatedAt),
}
diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go
index 3cbb142..13e3be4 100644
--- a/app/backend/internal/api/routes.go
+++ b/app/backend/internal/api/routes.go
@@ -96,6 +96,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Next()
})
+ // Seed the official service template catalog (idempotent upserts).
+ go SeedOfficialTemplates(context.Background(), db)
+
go func() {
if err := haManager.Start(context.Background()); err != nil {
log.Printf("HA manager exited: %v", err)
@@ -136,7 +139,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if redis == nil {
redisStatus = "unhealthy"
- checks["redis"] = redisStatus
+ checks["redis"] = "unhealthy"
checks["redisError"] = "redis client not initialized"
overallStatus = "degraded"
statusCode = http.StatusServiceUnavailable
@@ -177,163 +180,182 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/register", handleRegister)
}
- // Protected routes (authentication required)
- protected := v1.Group("/")
- protected.Use(middleware.Auth(cfg.JWTSecret))
+ // Read routes — public browsing. OptionalAuth resolves a session when
+ // present so handlers can widen visibility for owners/admins; anonymous
+ // callers see approved resources only.
+ read := v1.Group("/")
+ read.Use(middleware.OptionalAuth(cfg.JWTSecret))
{
- // User routes
- protected.GET("/user/profile", handleGetProfile)
- protected.PUT("/user/profile", handleUpdateProfile)
- protected.POST("/users", handleCreateUser)
+ read.GET("/projects", handleGetProjects)
+ read.GET("/projects/:id", handleGetProject)
+ read.GET("/projects/:id/services", handleGetServices)
+ read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
+ read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
+ read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
+ read.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
- // Platform settings (admin-gated inside handlers)
- protected.GET("/settings", handleGetSettings)
- protected.PUT("/settings", handleUpdateSettings)
+ read.GET("/services/:id", handleGetService)
+ read.GET("/services/:id/metrics", handleGetServiceMetrics)
+ read.GET("/services/:id/runtime", handleGetServiceRuntime)
+ read.GET("/services/:id/deployments", handleGetDeployments)
+ read.GET("/deployments", handleGetRecentDeployments)
+ read.GET("/deployments/:id", handleGetDeployment)
- // Project routes
- protected.GET("/projects", handleGetProjects)
- protected.POST("/projects", handleCreateProject)
+ read.GET("/databases", databaseHandler.GetDatabases)
+ read.GET("/databases/:id", databaseHandler.GetDatabase)
- // Service routes (nested under projects)
- protected.GET("/projects/:id/services", handleGetServices)
- protected.POST("/projects/:id/services", handleCreateService)
+ read.GET("/templates", handleGetTemplates)
+ read.GET("/templates/:id", handleGetTemplate)
- // Generic project routes
- protected.GET("/projects/:id", handleGetProject)
- protected.PUT("/projects/:id", handleUpdateProject)
- protected.DELETE("/projects/:id", handleDeleteProject)
+ read.GET("/cron-jobs", handleGetCronJobs)
+ read.GET("/cron-jobs/:id", handleGetCronJob)
+ read.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
- // Service routes
- protected.GET("/services/:id", handleGetService)
- protected.PUT("/services/:id", handleUpdateService)
- protected.DELETE("/services/:id", handleDeleteService)
- protected.GET("/services/:id/metrics", handleGetServiceMetrics)
+ read.GET("/security/scans/:id", securityHandler.GetSecurityScan)
+ read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
+ read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
- // Runtime lifecycle (Railway-style)
- protected.GET("/services/:id/runtime", handleGetServiceRuntime)
- protected.POST("/services/:id/start", handleServiceStart)
- protected.POST("/services/:id/stop", handleServiceStop)
- protected.POST("/services/:id/restart", handleServiceRestart)
- protected.POST("/services/:id/redeploy", handleServiceRedeploy)
+ read.GET("/preview-environments/:id", handleGetPreviewEnvironment)
- // Deployment routes
- protected.GET("/services/:id/deployments", handleGetDeployments)
- protected.POST("/services/:id/deployments", handleCreateDeployment)
- protected.GET("/deployments", handleGetRecentDeployments)
- protected.GET("/deployments/:id", handleGetDeployment)
- protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
+ read.GET("/system/upgrade/status", handleGetUpgradeStatus)
+ read.GET("/system/host", handleGetHostMonitoring)
- // Environment variables routes
- protected.GET("/services/:id/variables", handleGetVariables)
- protected.PUT("/services/:id/variables", handleUpdateVariables)
+ // HA + scaling read endpoints (status, policies, health, alerts)
+ haAPIManager.RegisterReadRoutes(read)
+ scalingHandler.RegisterReadRoutes(read)
+ }
- // Logs routes
- protected.GET("/services/:id/logs", handleGetLogs)
- protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
+ // Authenticated routes — a valid session is required and handlers
+ // enforce ownership; platform admins additionally pass every check.
+ authed := v1.Group("/")
+ authed.Use(middleware.Auth(cfg.JWTSecret))
+ {
+ authed.GET("/user/profile", handleGetProfile)
+ authed.PUT("/user/profile", handleUpdateProfile)
- // One-off exec console (docker exec, 30s ceiling)
- protected.POST("/services/:id/exec", handleExecInService)
-
- // Git integration routes
- protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
- protected.POST("/git/github-app/connect", handleConnectGitHubApp)
- protected.GET("/git/providers", handleGetGitProviders)
- protected.POST("/git/providers", handleCreateGitProvider)
- protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
- protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
- protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
- protected.POST("/git/repositories/connect", handleConnectGitRepository)
- protected.GET("/git/repositories", handleGetConnectedRepositories)
- protected.POST("/git/webhooks", handleCreateWebhook)
-
- // Build routes
- protected.POST("/builds", buildHandler.StartBuild)
- protected.GET("/builds", buildHandler.ListBuilds)
- protected.GET("/builds/:id", buildHandler.GetBuildStatus)
- protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
- protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
- protected.POST("/builds/plan", buildHandler.GetBuildPlan)
- protected.GET("/builds/detect", buildHandler.DetectBuildType)
-
- // System routes
- protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
- protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
- protected.GET("/system/host", handleGetHostMonitoring)
-
- // Scaling routes
- scalingHandler.RegisterRoutes(protected)
- haAPIManager.RegisterRoutes(protected)
-
- // Database routes
- protected.GET("/databases", databaseHandler.GetDatabases)
- protected.POST("/databases", databaseHandler.CreateDatabase)
- protected.GET("/databases/:id", databaseHandler.GetDatabase)
- protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
- protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
- protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
- protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
- protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
- protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
-
- // Notification routes
- protected.GET("/notifications", handleListNotifications)
- protected.POST("/notifications/:id/read", handleMarkNotificationRead)
- protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
-
- // Node Agent routes
- agentHandler.SetupRoutes(protected)
-
- // Preview Environments routes
- protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
- protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
- protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
- protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
- protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
- protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
- protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
-
- // Security routes
- protected.POST("/security/scans", securityHandler.StartSecurityScan)
- protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
- protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
- protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
- protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
- protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
- protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
- protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
- protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
- protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
- protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
+ authed.GET("/notifications", handleListNotifications)
+ authed.POST("/notifications/:id/read", handleMarkNotificationRead)
+ authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// WebSocket endpoint
- protected.GET("/ws", handleWebSocket)
+ authed.GET("/ws", handleWebSocket)
- // Templates routes
- protected.GET("/templates", handleGetTemplates)
- protected.GET("/templates/:id", handleGetTemplate)
- protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
+ // Owned-resource mutations — handlers scope to the caller and
+ // admit admins. New projects land unapproved until an admin
+ // publishes them.
+ authed.POST("/projects", handleCreateProject)
+ authed.POST("/projects/:id/services", handleCreateService)
+ authed.PUT("/projects/:id", handleUpdateProject)
+ authed.DELETE("/projects/:id", handleDeleteProject)
- // Cron Jobs routes
- protected.GET("/cron-jobs", handleGetCronJobs)
- protected.POST("/cron-jobs", handleCreateCronJob)
- protected.GET("/cron-jobs/:id", handleGetCronJob)
- protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
- protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
- protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
- protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
+ authed.PUT("/services/:id", handleUpdateService)
+ authed.DELETE("/services/:id", handleDeleteService)
+ authed.POST("/services/:id/start", handleServiceStart)
+ authed.POST("/services/:id/stop", handleServiceStop)
+ authed.POST("/services/:id/restart", handleServiceRestart)
+ authed.POST("/services/:id/redeploy", handleServiceRedeploy)
+ authed.POST("/services/:id/deployments", handleCreateDeployment)
+ authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
- // Audit Logs routes
- protected.GET("/audit-logs", handleGetAuditLogs)
- protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+ // Environment variables — secrets, owner/admin only.
+ authed.GET("/services/:id/variables", handleGetVariables)
+ authed.PUT("/services/:id/variables", handleUpdateVariables)
+
+ // Runtime logs — container stdout routinely echoes secrets,
+ // so these stay behind authentication like build logs.
+ authed.GET("/services/:id/logs", handleGetLogs)
+ authed.GET("/deployments/:id/logs", handleGetDeploymentLogs)
+
+ // One-off exec console (docker exec, 30s ceiling)
+ authed.POST("/services/:id/exec", handleExecInService)
+
+ // Git integration — providers carry per-user credentials.
+ authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
+ authed.POST("/git/github-app/connect", handleConnectGitHubApp)
+ authed.GET("/git/providers", handleGetGitProviders)
+ authed.POST("/git/providers", handleCreateGitProvider)
+ authed.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
+ authed.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
+ authed.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
+ authed.POST("/git/repositories/connect", handleConnectGitRepository)
+ authed.GET("/git/repositories", handleGetConnectedRepositories)
+ authed.POST("/git/webhooks", handleCreateWebhook)
+
+ // Builds have no per-user scoping and logs may carry secrets —
+ // keep every build endpoint behind authentication.
+ authed.POST("/builds", buildHandler.StartBuild)
+ authed.GET("/builds", buildHandler.ListBuilds)
+ authed.GET("/builds/:id", buildHandler.GetBuildStatus)
+ authed.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
+ authed.GET("/builds/detect", buildHandler.DetectBuildType)
+ authed.POST("/builds/:id/cancel", buildHandler.CancelBuild)
+ authed.POST("/builds/plan", buildHandler.GetBuildPlan)
+
+ authed.POST("/databases", databaseHandler.CreateDatabase)
+ authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
+ authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
+ authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
+ authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
+ authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
+ authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
+
+ authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
+ authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
+ authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
+ authed.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
+
+ authed.POST("/security/scans", securityHandler.StartSecurityScan)
+ authed.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
+ authed.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
+ authed.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
+ authed.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
+
+ // User templates — ownership enforced inside the handlers.
+ authed.POST("/templates", handleCreateTemplate)
+ authed.PUT("/templates/:id", handleUpdateTemplate)
+ authed.DELETE("/templates/:id", handleDeleteTemplate)
+ authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
+
+ authed.POST("/cron-jobs", handleCreateCronJob)
+ authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
+ authed.DELETE("/cron-jobs/:id", handleDeleteCronJob)
+ authed.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
+ }
+
+ // Admin routes — platform-wide controls and cross-user management.
+ admin := v1.Group("/")
+ admin.Use(middleware.Auth(cfg.JWTSecret))
+ admin.Use(middleware.RequireAdmin())
+ {
+ admin.GET("/admin/overview", handleAdminOverview)
+ admin.GET("/admin/users", handleAdminListUsers)
+ admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
+ admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
+
+ admin.POST("/users", handleCreateUser)
+
+ admin.GET("/settings", handleGetSettings)
+ admin.PUT("/settings", handleUpdateSettings)
+
+ admin.POST("/system/upgrade/pull", handlePullUpgradeImage)
+ admin.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
+
+ // HA + scaling + agent mutations — platform-level controls.
+ haAPIManager.RegisterAdminRoutes(admin)
+ scalingHandler.RegisterAdminRoutes(admin)
+ agentHandler.SetupAdminRoutes(admin)
+
+ admin.GET("/audit-logs", handleGetAuditLogs)
+ admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
- gateway := protected.Group("/gateway")
+ gateway := admin.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
- gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
+ gateway.GET("/services/:id", handleAPwhyServiceValidate)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go
index bdb8300..48eaf2a 100644
--- a/app/backend/internal/api/runtime.go
+++ b/app/backend/internal/api/runtime.go
@@ -225,13 +225,56 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
return service, true
}
+// loadReadableService loads a service when the caller may read its project:
+// approved projects are public, unapproved ones need owner/member/admin.
+func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
+ serviceID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
+ return Service{}, false
+ }
+
+ var service Service
+ var projectID uuid.UUID
+ err = db.QueryRow(
+ `SELECT s.id, s.project_id, s.name,
+ COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
+ COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
+ COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
+ COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
+ COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
+ COALESCE(s.replicas, 1), COALESCE(s.port, 0),
+ COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
+ COALESCE(s.restart_policy, 'unless-stopped'),
+ s.created_at, s.updated_at, s.project_id
+ FROM services s
+ WHERE s.id = $1`,
+ serviceID,
+ ).Scan(
+ &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
+ &service.Image, &service.Command, &service.Environment, &service.GitRepo,
+ &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
+ &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
+ &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID,
+ )
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
+ return Service{}, false
+ }
+ if _, allowed := projectReadAccess(c, db, projectID); !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
+ return Service{}, false
+ }
+ return service, true
+}
+
// handleServiceRuntime returns live container state for a service.
func handleGetServiceRuntime(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
- service, ok := loadOwnedService(c, db)
+ service, ok := loadReadableService(c, db)
if !ok {
return
}
diff --git a/app/backend/internal/api/scaling.go b/app/backend/internal/api/scaling.go
index 1968722..528f0ba 100644
--- a/app/backend/internal/api/scaling.go
+++ b/app/backend/internal/api/scaling.go
@@ -24,31 +24,39 @@ func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
}
}
-// RegisterRoutes registers scaling routes
-func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
+// RegisterReadRoutes registers public scaling read endpoints.
+func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.GET("/policies", h.GetScalingPolicies)
- scaling.POST("/policies", h.SetScalingPolicy)
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
- scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
- scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.GET("/services", h.GetServiceStates)
scaling.GET("/services/:serviceId", h.GetServiceState)
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
- scaling.POST("/services/:serviceId/scale", h.ManualScale)
-
scaling.GET("/status", h.GetScalingStatus)
- scaling.POST("/enable", h.EnableAutoScaler)
- scaling.POST("/disable", h.DisableAutoScaler)
scaling.GET("/metrics", h.GetScalingMetrics)
scaling.GET("/events", h.GetScalingEvents)
}
}
+// RegisterAdminRoutes registers scaling mutation endpoints.
+func (h *ScalingHandler) RegisterAdminRoutes(router *gin.RouterGroup) {
+ scaling := router.Group("/scaling")
+ {
+ scaling.POST("/policies", h.SetScalingPolicy)
+ scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
+ scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
+
+ scaling.POST("/services/:serviceId/scale", h.ManualScale)
+
+ scaling.POST("/enable", h.EnableAutoScaler)
+ scaling.POST("/disable", h.DisableAutoScaler)
+ }
+}
+
// GetScalingPolicies returns all scaling policies
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
diff --git a/app/backend/internal/api/scaling_test.go b/app/backend/internal/api/scaling_test.go
index ff9721f..ba592d7 100644
--- a/app/backend/internal/api/scaling_test.go
+++ b/app/backend/internal/api/scaling_test.go
@@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewScalingHandler(autoScaler)
- handler.RegisterRoutes(router.Group("/api/v1"))
+ group := router.Group("/api/v1")
+ handler.RegisterReadRoutes(group)
+ handler.RegisterAdminRoutes(group)
return router
}
diff --git a/app/backend/internal/api/security.go b/app/backend/internal/api/security.go
index 0a61e26..05aa255 100644
--- a/app/backend/internal/api/security.go
+++ b/app/backend/internal/api/security.go
@@ -589,16 +589,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
}
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
- userIDValue, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return "", false
- }
- userID, ok := userIDValue.(string)
- if !ok || userID == "" {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
- return "", false
- }
+ userID := optionalUserID(c)
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
@@ -611,12 +602,14 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
SELECT 1
FROM projects p
WHERE p.id = $1
- AND (p.owner_id = $2 OR EXISTS (
+ AND (p.is_approved
+ OR $3::bool
+ OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)`,
- projectID, userID,
+ projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go
index a5932ec..b4e2c47 100644
--- a/app/backend/internal/api/services.go
+++ b/app/backend/internal/api/services.go
@@ -91,28 +91,14 @@ func handleGetServices(c *gin.Context) {
return
}
- // Check if project exists and user has access
- var project Project
- err = db.(*database.DB).QueryRow(
- "SELECT id, name, owner_id FROM projects WHERE id = $1",
- projectID,
- ).Scan(&project.ID, &project.Name, &project.OwnerID)
-
- if err != nil {
+ // Approved projects are public; unapproved ones need owner/member/admin.
+ projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
+ if !projectExists {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
-
- // Get user ID from JWT token (set by auth middleware)
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- // Check if user owns the project
- if project.OwnerID != userID.(string) {
- c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
+ if !allowed {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
@@ -217,7 +203,7 @@ func handleCreateService(c *gin.Context) {
}
// Check if user owns the project
- if project.OwnerID != userID.(string) {
+ if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -344,14 +330,9 @@ func handleGetService(c *gin.Context) {
return
}
- // Get user ID from JWT token
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- // Get service with project ownership check
+ // Get service — public when its project is approved, otherwise
+ // owner/member/admin only.
+ userID := optionalUserUUID(c)
var service Service
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name,
@@ -371,8 +352,9 @@ func handleGetService(c *gin.Context) {
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1 AND p.owner_id = $2`,
- serviceID, userID,
+ WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
+ OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
+ serviceID, userID, contextIsAdmin(c),
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
@@ -438,8 +420,8 @@ func handleUpdateService(c *gin.Context) {
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1 AND p.owner_id = $2`,
- serviceID, userID,
+ WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
+ serviceID, userID, contextIsAdmin(c),
).Scan(
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
&existingService.Status, &existingService.Image, &existingService.Command,
diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go
index ea91ddb..8f05600 100644
--- a/app/backend/internal/api/templates.go
+++ b/app/backend/internal/api/templates.go
@@ -3,16 +3,19 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
+ "context"
"database/sql"
"encoding/json"
"errors"
"fmt"
+ "log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
+ "github.com/sqlc-dev/pqtype"
)
type ServiceTemplate struct {
@@ -24,6 +27,7 @@ type ServiceTemplate struct {
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
+ OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
@@ -67,12 +71,18 @@ func handleGetTemplates(c *gin.Context) {
ctx := c.Request.Context()
queries := sqlcdb.New(db.DB)
+ userID := optionalUserUUID(c)
+ owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
+
var templateRows []sqlcdb.ServiceTemplate
var err error
if category != "" {
- templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category)
+ templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
+ Category: category,
+ OwnerID: owner,
+ })
} else {
- templateRows, err = queries.ListServiceTemplates(ctx)
+ templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
@@ -103,7 +113,7 @@ func handleGetTemplate(c *gin.Context) {
}
t := mapSQLCTemplate(row)
- if t.ID == "" {
+ if t.ID == "" || !templateVisibleTo(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -169,7 +179,7 @@ func handleCreateFromTemplate(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
return
}
- if ownerID.String() != userID {
+ if ownerID.String() != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -185,8 +195,8 @@ func handleCreateFromTemplate(c *gin.Context) {
}
template := mapSQLCTemplate(templateRow)
- if template.ID == "" {
- c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
+ if template.ID == "" || !templateVisibleTo(c, template) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -363,6 +373,243 @@ func handleCreateFromTemplate(c *gin.Context) {
})
}
+// templateWriteRequest is the admin payload for creating/updating a user
+// template. config must decode into TemplateConfig; variables into
+// []TemplateVariable.
+type templateWriteRequest struct {
+ Name string `json:"name" binding:"required"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ Logo string `json:"logo"`
+ Config json.RawMessage `json:"config" binding:"required"`
+ Variables json.RawMessage `json:"variables"`
+}
+
+var templateCategories = map[string]bool{
+ "web": true, "frontend": true, "api": true, "database": true,
+ "worker": true, "cron": true, "app": true, "custom": true,
+}
+
+func normalizeTemplateCategory(category string) string {
+ normalized := strings.ToLower(strings.TrimSpace(category))
+ if templateCategories[normalized] {
+ return normalized
+ }
+ return "custom"
+}
+
+func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool {
+ req.Name = strings.TrimSpace(req.Name)
+ if len(req.Name) < 2 || len(req.Name) > 120 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"})
+ return false
+ }
+ req.Category = normalizeTemplateCategory(req.Category)
+ req.Logo = strings.TrimSpace(req.Logo)
+ if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"})
+ return false
+ }
+ if len(req.Config) == 0 || len(req.Config) > 64*1024 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"})
+ return false
+ }
+ var config TemplateConfig
+ if err := json.Unmarshal(req.Config, &config); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"})
+ return false
+ }
+ if _, err := normalizeTemplateServiceType(config.Type); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return false
+ }
+ if len(req.Variables) > 0 {
+ if len(req.Variables) > 64*1024 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"})
+ return false
+ }
+ var variables []TemplateVariable
+ if err := json.Unmarshal(req.Variables, &variables); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"})
+ return false
+ }
+ for _, v := range variables {
+ if strings.TrimSpace(v.Key) == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"})
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage {
+ if len(raw) == 0 {
+ return pqtype.NullRawMessage{}
+ }
+ return pqtype.NullRawMessage{RawMessage: raw, Valid: true}
+}
+
+func handleCreateTemplate(c *gin.Context) {
+ userID, ok := requireAuthenticatedUserID(c)
+ if !ok {
+ return
+ }
+ db := c.MustGet("db").(*database.DB)
+ queries := sqlcdb.New(db.DB)
+
+ var req templateWriteRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if !validateTemplateWrite(c, &req) {
+ return
+ }
+
+ ownerUUID, err := uuid.Parse(userID)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
+ return
+ }
+
+ id := "usr-" + uuid.NewString()
+ if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
+ ID: id,
+ Name: req.Name,
+ Description: nullableText(strings.TrimSpace(req.Description)),
+ Category: req.Category,
+ Logo: nullableText(req.Logo),
+ Config: req.Config,
+ Variables: templateVariablesParam(req.Variables),
+ OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
+ }); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
+ return
+ }
+
+ LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name})
+
+ row, err := queries.GetServiceTemplateByID(c.Request.Context(), id)
+ if err != nil {
+ c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}})
+ return
+ }
+ c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)})
+}
+
+func handleUpdateTemplate(c *gin.Context) {
+ userID, ok := requireAuthenticatedUserID(c)
+ if !ok {
+ return
+ }
+ db := c.MustGet("db").(*database.DB)
+ queries := sqlcdb.New(db.DB)
+ templateID := c.Param("id")
+
+ var req templateWriteRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if !validateTemplateWrite(c, &req) {
+ return
+ }
+
+ ownerUUID, err := uuid.Parse(userID)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
+ return
+ }
+
+ rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{
+ Name: req.Name,
+ Description: nullableText(strings.TrimSpace(req.Description)),
+ Category: req.Category,
+ Logo: nullableText(req.Logo),
+ Config: req.Config,
+ Variables: templateVariablesParam(req.Variables),
+ ID: templateID,
+ OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
+ return
+ }
+ if rows == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"})
+ return
+ }
+
+ LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name})
+
+ row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"message": "Template updated"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)})
+}
+
+func handleDeleteTemplate(c *gin.Context) {
+ userID, ok := requireAuthenticatedUserID(c)
+ if !ok {
+ return
+ }
+ db := c.MustGet("db").(*database.DB)
+ queries := sqlcdb.New(db.DB)
+ templateID := c.Param("id")
+
+ ownerUUID, err := uuid.Parse(userID)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
+ return
+ }
+
+ rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{
+ ID: templateID,
+ OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
+ return
+ }
+ if rows == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"})
+ return
+ }
+
+ LogAudit(userID, "template", templateID, "delete", nil)
+ c.JSON(http.StatusOK, gin.H{"message": "Template deleted"})
+}
+
+// SeedOfficialTemplates upserts the built-in catalog on startup so new
+// templates ship with releases without needing a migration per entry.
+func SeedOfficialTemplates(ctx context.Context, db *database.DB) {
+ if db == nil || db.DB == nil {
+ return
+ }
+ queries := sqlcdb.New(db.DB)
+ for _, t := range SeedTemplates() {
+ variables := pqtype.NullRawMessage{}
+ if strings.TrimSpace(t.Variables) != "" {
+ variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true}
+ }
+ if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{
+ ID: t.ID,
+ Name: t.Name,
+ Description: nullableText(t.Description),
+ Category: t.Category,
+ Logo: nullableText(t.Logo),
+ Config: json.RawMessage(t.Config),
+ Variables: variables,
+ IsOfficial: sql.NullBool{Bool: true, Valid: true},
+ }); err != nil {
+ log.Printf("Failed to seed template %s: %v", t.ID, err)
+ }
+ }
+}
+
func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{
{
@@ -505,6 +752,167 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
IsOfficial: true,
},
+ // Self-hosted applications — runtime doubles as the image reference.
+ {
+ ID: "tpl-nextcloud",
+ Name: "Nextcloud",
+ Description: "Self-hosted file sync, share and collaboration platform",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/nextcloud",
+ Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`,
+ Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-gitea",
+ Name: "Gitea",
+ Description: "Lightweight self-hosted Git service",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/gitea",
+ Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`,
+ Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-ghost",
+ Name: "Ghost",
+ Description: "Independent publishing platform for blogs and newsletters",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/ghost",
+ Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`,
+ Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-uptime-kuma",
+ Name: "Uptime Kuma",
+ Description: "Self-hosted monitoring tool for services and websites",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/uptimerobot",
+ Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`,
+ Variables: `[]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-vaultwarden",
+ Name: "Vaultwarden",
+ Description: "Lightweight Bitwarden-compatible password manager",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/bitwarden",
+ Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`,
+ Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-jellyfin",
+ Name: "Jellyfin",
+ Description: "Free software media system for movies, shows and music",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/jellyfin",
+ Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`,
+ Variables: `[]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-n8n",
+ Name: "n8n",
+ Description: "Workflow automation tool with hundreds of integrations",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/n8n",
+ Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`,
+ Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-grafana",
+ Name: "Grafana",
+ Description: "Observability dashboards for metrics, logs and traces",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/grafana",
+ Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`,
+ Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-immich",
+ Name: "Immich",
+ Description: "Self-hosted photo and video backup",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/immich",
+ Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`,
+ Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-meilisearch",
+ Name: "Meilisearch",
+ Description: "Fast, typo-tolerant search engine API",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/meilisearch",
+ Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`,
+ Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-plausible",
+ Name: "Plausible Analytics",
+ Description: "Lightweight, privacy-friendly web analytics",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/plausibleanalytics",
+ Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`,
+ Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-minio",
+ Name: "MinIO",
+ Description: "S3-compatible object storage",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/minio",
+ Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`,
+ Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-appwrite",
+ Name: "Appwrite",
+ Description: "Backend-as-a-service for web and mobile apps",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/appwrite",
+ Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`,
+ Variables: `[]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-wordpress",
+ Name: "WordPress",
+ Description: "Classic CMS powering a large share of the web",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/wordpress",
+ Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`,
+ Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-paperless",
+ Name: "Paperless-ngx",
+ Description: "Document management system with OCR",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/paperlessngx",
+ Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`,
+ Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
+ IsOfficial: true,
+ },
+ {
+ ID: "tpl-ntfy",
+ Name: "ntfy",
+ Description: "Push notification service over HTTP",
+ Category: "app",
+ Logo: "https://cdn.simpleicons.org/ntfy",
+ Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`,
+ Variables: `[]`,
+ IsOfficial: true,
+ },
}
return templates
}
@@ -605,12 +1013,30 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
}
}
+// templateVisibleTo reports whether the caller may see a template: official
+// templates are public, user templates are owner/admin only.
+func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
+ if t.IsOfficial {
+ return true
+ }
+ if contextIsAdmin(c) {
+ return true
+ }
+ userID := optionalUserID(c)
+ return userID != "" && t.OwnerID == userID
+}
+
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
variables := "[]"
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
variables = string(row.Variables.RawMessage)
}
+ ownerID := ""
+ if row.OwnerID.Valid {
+ ownerID = row.OwnerID.UUID.String()
+ }
+
return ServiceTemplate{
ID: row.ID,
Name: row.Name,
@@ -620,6 +1046,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Config: string(row.Config),
Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
+ OwnerID: ownerID,
CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt),
}
diff --git a/app/backend/internal/api/variables.go b/app/backend/internal/api/variables.go
index 8a53dd9..709b22a 100644
--- a/app/backend/internal/api/variables.go
+++ b/app/backend/internal/api/variables.go
@@ -47,26 +47,12 @@ func handleGetVariables(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- var ownerCheck string
- err = db.(*database.DB).QueryRow(
- `SELECT p.owner_id FROM services s
- JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1`,
- serviceID,
- ).Scan(&ownerCheck)
-
- if err != nil {
+ projectID, found := serviceProjectID(db.(*database.DB), serviceID)
+ if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
-
- if ownerCheck != userID.(string) {
+ if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -123,26 +109,12 @@ func handleUpdateVariables(c *gin.Context) {
return
}
- userID, exists := c.Get("user_id")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
- return
- }
-
- var ownerCheck string
- err = db.(*database.DB).QueryRow(
- `SELECT p.owner_id FROM services s
- JOIN projects p ON s.project_id = p.id
- WHERE s.id = $1`,
- serviceID,
- ).Scan(&ownerCheck)
-
- if err != nil {
+ projectID, found := serviceProjectID(db.(*database.DB), serviceID)
+ if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
-
- if ownerCheck != userID.(string) {
+ if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
diff --git a/app/backend/internal/config/config.go b/app/backend/internal/config/config.go
index ffe00d3..459f4b1 100644
--- a/app/backend/internal/config/config.go
+++ b/app/backend/internal/config/config.go
@@ -91,6 +91,12 @@ type Config struct {
// Dashboard UI Configuration
DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"`
+
+ // Admin bootstrap — when set, the account is provisioned/promoted on
+ // startup so the owner always has admin credentials that work.
+ AdminEmail string `env:"ADMIN_EMAIL" default:""`
+ AdminPassword string `env:"ADMIN_PASSWORD" default:""`
+ AdminName string `env:"ADMIN_NAME" default:""`
}
// Environment represents the application environment
@@ -181,6 +187,10 @@ func Load() *Config {
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
+ cfg.AdminEmail = getenv("ADMIN_EMAIL", "")
+ cfg.AdminPassword = getenv("ADMIN_PASSWORD", "")
+ cfg.AdminName = getenv("ADMIN_NAME", "")
+
// Validate configuration
if err := cfg.Validate(); err != nil {
panic(fmt.Sprintf("Invalid configuration: %v", err))
diff --git a/app/backend/internal/database/postgres.go b/app/backend/internal/database/postgres.go
index 4ba7900..7a08d24 100644
--- a/app/backend/internal/database/postgres.go
+++ b/app/backend/internal/database/postgres.go
@@ -55,5 +55,5 @@ func (db *DB) Health(ctx context.Context) error {
// Stats returns connection pool statistics for monitoring
func (db *DB) Stats() sql.DBStats {
- return db.Stats()
+ return db.DB.Stats()
}
diff --git a/app/backend/internal/database/sqlcdb/databases.sql.go b/app/backend/internal/database/sqlcdb/databases.sql.go
index 2c969c7..48ac8e6 100644
--- a/app/backend/internal/database/sqlcdb/databases.sql.go
+++ b/app/backend/internal/database/sqlcdb/databases.sql.go
@@ -149,37 +149,18 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
return i, err
}
-const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
-SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
-WHERE id = $1 AND user_id = $2
+WHERE id = $1
`
-type GetDatabaseServiceByIDAndUserParams struct {
- ID string `json:"id"`
- UserID string `json:"user_id"`
-}
-
-type GetDatabaseServiceByIDAndUserRow struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"`
- Status string `json:"status"`
- Version string `json:"version"`
- Plan string `json:"plan"`
- Region string `json:"region"`
- ConnectionUrl sql.NullString `json:"connection_url"`
- BackupSchedule sql.NullString `json:"backup_schedule"`
- NextBackupAt sql.NullTime `json:"next_backup_at"`
- CreatedAt sql.NullTime `json:"created_at"`
- UpdatedAt sql.NullTime `json:"updated_at"`
-}
-
-func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
- row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
- var i GetDatabaseServiceByIDAndUserRow
+func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) {
+ row := q.db.QueryRowContext(ctx, getDatabaseServiceByID, id)
+ var i DatabaseService
err := row.Scan(
&i.ID,
+ &i.UserID,
&i.Name,
&i.Type,
&i.Status,
@@ -195,6 +176,94 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
return i, err
}
+const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+FROM database_services
+WHERE id = $1 AND user_id = $2
+`
+
+type GetDatabaseServiceByIDAndUserParams struct {
+ ID string `json:"id"`
+ UserID string `json:"user_id"`
+}
+
+func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) {
+ row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
+ var i DatabaseService
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.Name,
+ &i.Type,
+ &i.Status,
+ &i.Version,
+ &i.Plan,
+ &i.Region,
+ &i.ConnectionUrl,
+ &i.BackupSchedule,
+ &i.NextBackupAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getDatabaseServiceOwnerID = `-- name: GetDatabaseServiceOwnerID :one
+SELECT user_id
+FROM database_services
+WHERE id = $1
+`
+
+func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) {
+ row := q.db.QueryRowContext(ctx, getDatabaseServiceOwnerID, id)
+ var user_id string
+ err := row.Scan(&user_id)
+ return user_id, err
+}
+
+const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+FROM database_services
+ORDER BY created_at DESC
+`
+
+func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) {
+ rows, err := q.db.QueryContext(ctx, listAllDatabaseServices)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []DatabaseService{}
+ for rows.Next() {
+ var i DatabaseService
+ if err := rows.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.Name,
+ &i.Type,
+ &i.Status,
+ &i.Version,
+ &i.Plan,
+ &i.Region,
+ &i.ConnectionUrl,
+ &i.BackupSchedule,
+ &i.NextBackupAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); 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 listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
FROM database_backups b
@@ -242,38 +311,24 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
}
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
-SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC
`
-type ListDatabaseServicesByUserRow struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"`
- Status string `json:"status"`
- Version string `json:"version"`
- Plan string `json:"plan"`
- Region string `json:"region"`
- ConnectionUrl sql.NullString `json:"connection_url"`
- BackupSchedule sql.NullString `json:"backup_schedule"`
- NextBackupAt sql.NullTime `json:"next_backup_at"`
- CreatedAt sql.NullTime `json:"created_at"`
- UpdatedAt sql.NullTime `json:"updated_at"`
-}
-
-func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
+func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) {
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
- items := []ListDatabaseServicesByUserRow{}
+ items := []DatabaseService{}
for rows.Next() {
- var i ListDatabaseServicesByUserRow
+ var i DatabaseService
if err := rows.Scan(
&i.ID,
+ &i.UserID,
&i.Name,
&i.Type,
&i.Status,
diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go
index 8c8ed01..0d39eb3 100644
--- a/app/backend/internal/database/sqlcdb/models.go
+++ b/app/backend/internal/database/sqlcdb/models.go
@@ -154,6 +154,7 @@ type Project struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
OwnerID uuid.UUID `json:"owner_id"`
+ IsApproved bool `json:"is_approved"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
@@ -205,6 +206,7 @@ type ServiceTemplate struct {
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"`
+ OwnerID uuid.NullUUID `json:"owner_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
diff --git a/app/backend/internal/database/sqlcdb/projects.sql.go b/app/backend/internal/database/sqlcdb/projects.sql.go
index 1c5e6f5..7625f49 100644
--- a/app/backend/internal/database/sqlcdb/projects.sql.go
+++ b/app/backend/internal/database/sqlcdb/projects.sql.go
@@ -16,25 +16,31 @@ const countProjectsByUser = `-- name: CountProjectsByUser :one
SELECT COUNT(*)::bigint AS total
FROM projects p
WHERE
- (p.owner_id = $1 OR EXISTS (
- SELECT 1
- FROM project_members pm
- WHERE pm.project_id = p.id AND pm.user_id = $1
- ))
+ (
+ 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
+ )
+ )
AND (
- $2::text IS NULL
- OR p.name ILIKE ('%' || $2::text || '%')
- OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
+ $3::text IS NULL
+ OR p.name ILIKE ('%' || $3::text || '%')
+ OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
)
`
type CountProjectsByUserParams struct {
- UserID uuid.UUID `json:"user_id"`
- Search sql.NullString `json:"search"`
+ UserID uuid.UUID `json:"user_id"`
+ IsAdmin bool `json:"is_admin"`
+ Search sql.NullString `json:"search"`
}
func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) {
- row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.Search)
+ row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.IsAdmin, arg.Search)
var total int64
err := row.Scan(&total)
return total, err
@@ -43,7 +49,7 @@ func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUs
const createProject = `-- name: CreateProject :one
INSERT INTO projects (name, description, owner_id)
VALUES ($1, $2, $3)
-RETURNING id, name, description, owner_id, created_at, updated_at
+RETURNING id, name, description, owner_id, is_approved, created_at, updated_at
`
type CreateProjectParams struct {
@@ -60,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
&i.Name,
&i.Description,
&i.OwnerID,
+ &i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -80,11 +87,13 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
}
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
-SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
+SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.id = $1
AND (
- p.owner_id = $2
+ p.is_approved
+ OR p.owner_id = $2
+ OR $3::bool
OR EXISTS (
SELECT 1
FROM project_members pm
@@ -96,16 +105,18 @@ WHERE p.id = $1
type GetProjectByIDForUserParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
+ IsAdmin bool `json:"is_admin"`
}
func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) {
- row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID)
+ row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID, arg.IsAdmin)
var i Project
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.OwnerID,
+ &i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -127,21 +138,22 @@ func (q *Queries) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID)
const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one
SELECT (CASE
- WHEN p.owner_id = $1 THEN 'owner'
+ WHEN p.owner_id = $1 OR $2::bool THEN 'owner'
ELSE COALESCE(pm.role, '')
END)::text AS role
FROM projects p
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
-WHERE p.id = $2
+WHERE p.id = $3
`
type GetProjectRoleForUserParams struct {
UserID uuid.UUID `json:"user_id"`
+ IsAdmin bool `json:"is_admin"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) {
- row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.ProjectID)
+ row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.IsAdmin, arg.ProjectID)
var role string
err := row.Scan(&role)
return role, err
@@ -162,12 +174,52 @@ func (q *Queries) InsertProjectEnvironment(ctx context.Context, arg InsertProjec
return err
}
+const listPendingProjects = `-- name: ListPendingProjects :many
+SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
+FROM projects p
+WHERE p.is_approved = false
+ORDER BY p.created_at DESC
+`
+
+func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
+ rows, err := q.db.QueryContext(ctx, listPendingProjects)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Project{}
+ for rows.Next() {
+ var i Project
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.OwnerID,
+ &i.IsApproved,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); 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 listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
+
SELECT
p.id,
p.name,
p.description,
p.owner_id,
+ p.is_approved,
p.created_at,
p.updated_at,
COUNT(DISTINCT s.id)::bigint AS service_count,
@@ -185,23 +237,29 @@ FROM projects p
LEFT JOIN services s ON s.project_id = p.id
LEFT JOIN deployments d ON d.service_id = s.id
WHERE
- (p.owner_id = $1 OR EXISTS (
- SELECT 1
- FROM project_members pm
- WHERE pm.project_id = p.id AND pm.user_id = $1
- ))
- AND (
- $2::text IS NULL
- OR p.name ILIKE ('%' || $2::text || '%')
- OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
+ (
+ 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
+ )
)
-GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
+ AND (
+ $3::text IS NULL
+ OR p.name ILIKE ('%' || $3::text || '%')
+ OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
+ )
+GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
ORDER BY p.updated_at DESC
-LIMIT $4 OFFSET $3
+LIMIT $5 OFFSET $4
`
type ListProjectsWithStatsByUserParams struct {
UserID uuid.UUID `json:"user_id"`
+ IsAdmin bool `json:"is_admin"`
Search sql.NullString `json:"search"`
OffsetCount int32 `json:"offset_count"`
LimitCount int32 `json:"limit_count"`
@@ -212,6 +270,7 @@ type ListProjectsWithStatsByUserRow struct {
Name string `json:"name"`
Description sql.NullString `json:"description"`
OwnerID uuid.UUID `json:"owner_id"`
+ IsApproved bool `json:"is_approved"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
@@ -220,9 +279,13 @@ type ListProjectsWithStatsByUserRow struct {
LastDeployment sql.NullTime `json:"last_deployment"`
}
+// Visibility model: a project is readable when it is approved for public
+// display, when the caller owns or is a member of it, or when the caller is a
+// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) {
rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser,
arg.UserID,
+ arg.IsAdmin,
arg.Search,
arg.OffsetCount,
arg.LimitCount,
@@ -239,6 +302,7 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
&i.Name,
&i.Description,
&i.OwnerID,
+ &i.IsApproved,
&i.CreatedAt,
&i.UpdatedAt,
&i.ServiceCount,
@@ -259,6 +323,25 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
return items, nil
}
+const setProjectApproved = `-- name: SetProjectApproved :execrows
+UPDATE projects
+SET is_approved = $1, updated_at = NOW()
+WHERE id = $2
+`
+
+type SetProjectApprovedParams struct {
+ IsApproved bool `json:"is_approved"`
+ ProjectID uuid.UUID `json:"project_id"`
+}
+
+func (q *Queries) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) {
+ result, err := q.db.ExecContext(ctx, setProjectApproved, arg.IsApproved, arg.ProjectID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected()
+}
+
const updateProjectByID = `-- name: UpdateProjectByID :execrows
UPDATE projects
SET
diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go
index 7d03186..1f1ef83 100644
--- a/app/backend/internal/database/sqlcdb/querier.go
+++ b/app/backend/internal/database/sqlcdb/querier.go
@@ -25,10 +25,12 @@ type Querier interface {
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
+ CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
+ DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
@@ -36,7 +38,9 @@ type Querier interface {
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
- GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error)
+ GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
+ GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
+ GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
@@ -47,16 +51,21 @@ type Querier interface {
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
+ ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
- ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
+ ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
+ ListPendingProjects(ctx context.Context) ([]Project, error)
+ // Visibility model: a project is readable when it is approved for public
+ // display, when the caller owns or is a member of it, or when the caller is a
+ // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
- ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
- ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
+ ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
+ ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
@@ -66,6 +75,7 @@ type Querier interface {
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
+ SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
@@ -74,7 +84,9 @@ type Querier interface {
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
+ UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
+ UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
}
var _ Querier = (*Queries)(nil)
diff --git a/app/backend/internal/database/sqlcdb/templates.sql.go b/app/backend/internal/database/sqlcdb/templates.sql.go
index c9ba889..526b650 100644
--- a/app/backend/internal/database/sqlcdb/templates.sql.go
+++ b/app/backend/internal/database/sqlcdb/templates.sql.go
@@ -8,8 +8,10 @@ package sqlcdb
import (
"context"
"database/sql"
+ "encoding/json"
"github.com/google/uuid"
+ "github.com/sqlc-dev/pqtype"
)
const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one
@@ -122,6 +124,66 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
return err
}
+const createUserTemplate = `-- name: CreateUserTemplate :exec
+INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
+VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ $6,
+ $7,
+ false,
+ $8
+)
+`
+
+type CreateUserTemplateParams struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description sql.NullString `json:"description"`
+ Category string `json:"category"`
+ Logo sql.NullString `json:"logo"`
+ Config json.RawMessage `json:"config"`
+ Variables pqtype.NullRawMessage `json:"variables"`
+ OwnerID uuid.NullUUID `json:"owner_id"`
+}
+
+func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
+ _, err := q.db.ExecContext(ctx, createUserTemplate,
+ arg.ID,
+ arg.Name,
+ arg.Description,
+ arg.Category,
+ arg.Logo,
+ arg.Config,
+ arg.Variables,
+ arg.OwnerID,
+ )
+ return err
+}
+
+const deleteUserTemplate = `-- name: DeleteUserTemplate :execrows
+DELETE FROM service_templates
+WHERE id = $1
+ AND owner_id = $2
+ AND is_official = false
+`
+
+type DeleteUserTemplateParams struct {
+ ID string `json:"id"`
+ OwnerID uuid.NullUUID `json:"owner_id"`
+}
+
+func (q *Queries) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) {
+ result, err := q.db.ExecContext(ctx, deleteUserTemplate, arg.ID, arg.OwnerID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected()
+}
+
const getProjectOwnerID = `-- name: GetProjectOwnerID :one
SELECT owner_id
FROM projects
@@ -136,7 +198,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
}
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
-SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
+SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
WHERE id = $1
`
@@ -153,20 +215,28 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
&i.Config,
&i.Variables,
&i.IsOfficial,
+ &i.OwnerID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
-const listServiceTemplates = `-- name: ListServiceTemplates :many
-SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
+const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
+SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
+WHERE category = $1
+ AND (is_official = true OR owner_id = $2)
ORDER BY is_official DESC, name ASC
`
-func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) {
- rows, err := q.db.QueryContext(ctx, listServiceTemplates)
+type ListServiceTemplatesByCategoryForUserParams struct {
+ Category string `json:"category"`
+ OwnerID uuid.NullUUID `json:"owner_id"`
+}
+
+func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) {
+ rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategoryForUser, arg.Category, arg.OwnerID)
if err != nil {
return nil, err
}
@@ -183,6 +253,7 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
&i.Config,
&i.Variables,
&i.IsOfficial,
+ &i.OwnerID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -199,15 +270,15 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
return items, nil
}
-const listServiceTemplatesByCategory = `-- name: ListServiceTemplatesByCategory :many
-SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
+const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
+SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
-WHERE category = $1
+WHERE is_official = true OR owner_id = $1
ORDER BY is_official DESC, name ASC
`
-func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) {
- rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategory, category)
+func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) {
+ rows, err := q.db.QueryContext(ctx, listServiceTemplatesForUser, ownerID)
if err != nil {
return nil, err
}
@@ -224,6 +295,7 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
&i.Config,
&i.Variables,
&i.IsOfficial,
+ &i.OwnerID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -240,6 +312,48 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
return items, nil
}
+const updateUserTemplate = `-- name: UpdateUserTemplate :execrows
+UPDATE service_templates
+SET name = $1,
+ description = $2,
+ category = $3,
+ logo = $4,
+ config = $5,
+ variables = $6,
+ updated_at = NOW()
+WHERE id = $7
+ AND owner_id = $8
+ AND is_official = false
+`
+
+type UpdateUserTemplateParams struct {
+ Name string `json:"name"`
+ Description sql.NullString `json:"description"`
+ Category string `json:"category"`
+ Logo sql.NullString `json:"logo"`
+ Config json.RawMessage `json:"config"`
+ Variables pqtype.NullRawMessage `json:"variables"`
+ ID string `json:"id"`
+ OwnerID uuid.NullUUID `json:"owner_id"`
+}
+
+func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) {
+ result, err := q.db.ExecContext(ctx, updateUserTemplate,
+ arg.Name,
+ arg.Description,
+ arg.Category,
+ arg.Logo,
+ arg.Config,
+ arg.Variables,
+ arg.ID,
+ arg.OwnerID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected()
+}
+
const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -271,3 +385,51 @@ func (q *Queries) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvir
)
return err
}
+
+const upsertServiceTemplate = `-- name: UpsertServiceTemplate :exec
+INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
+VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ $6,
+ $7,
+ $8
+)
+ON CONFLICT (id) DO UPDATE
+SET name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ category = EXCLUDED.category,
+ logo = EXCLUDED.logo,
+ config = EXCLUDED.config,
+ variables = EXCLUDED.variables,
+ is_official = EXCLUDED.is_official,
+ updated_at = NOW()
+`
+
+type UpsertServiceTemplateParams struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description sql.NullString `json:"description"`
+ Category string `json:"category"`
+ Logo sql.NullString `json:"logo"`
+ Config json.RawMessage `json:"config"`
+ Variables pqtype.NullRawMessage `json:"variables"`
+ IsOfficial sql.NullBool `json:"is_official"`
+}
+
+func (q *Queries) UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error {
+ _, err := q.db.ExecContext(ctx, upsertServiceTemplate,
+ arg.ID,
+ arg.Name,
+ arg.Description,
+ arg.Category,
+ arg.Logo,
+ arg.Config,
+ arg.Variables,
+ arg.IsOfficial,
+ )
+ return err
+}
diff --git a/app/backend/internal/middleware/middleware.go b/app/backend/internal/middleware/middleware.go
index 012daa8..dafbd8c 100644
--- a/app/backend/internal/middleware/middleware.go
+++ b/app/backend/internal/middleware/middleware.go
@@ -69,41 +69,72 @@ func RequestID() gin.HandlerFunc {
}
}
+// resolveRequestUser authenticates the request via Bearer JWT or the Better
+// Auth session cookie. On success it returns the local user id and email.
+// tokenErr/hasToken mirror the legacy error contract for Auth.
+func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
+ tokenString, tokenErr, hasToken := extractJWTToken(c)
+ if tokenString != "" {
+ if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
+ userIDClaim, exists := claims["user_id"]
+ if exists {
+ id := strings.TrimSpace(fmt.Sprint(userIDClaim))
+ if _, err := uuid.Parse(id); err == nil {
+ if emailClaim, exists := claims["email"]; exists && emailClaim != nil {
+ email = strings.TrimSpace(fmt.Sprint(emailClaim))
+ }
+ return id, email, "", hasToken, true
+ }
+ }
+ tokenErr = "Invalid token claims"
+ } else if tokenErr == "" {
+ tokenErr = "Invalid token"
+ }
+ }
+
+ if sessionVerifier != nil {
+ if id, mail, verified := sessionVerifier.resolveUser(c); verified {
+ return id, mail, "", hasToken, true
+ }
+ }
+
+ return "", "", tokenErr, hasToken, 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))
+}
+
+func loadIsAdmin(c *gin.Context, userID 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
+ }
+ var isAdmin bool
+ if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
+ return false
+ }
+ return isAdmin
+}
+
// Auth middleware for JWT authentication
func Auth(jwtSecret string) gin.HandlerFunc {
sessionVerifier := newBetterAuthSessionVerifier()
return func(c *gin.Context) {
- tokenString, tokenErr, hasToken := extractJWTToken(c)
- if tokenString != "" {
- if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
- userIDClaim, exists := claims["user_id"]
- if exists {
- userID := strings.TrimSpace(fmt.Sprint(userIDClaim))
- if _, err := uuid.Parse(userID); err == nil {
- email := ""
- if emailClaim, ok := claims["email"]; ok && emailClaim != nil {
- email = strings.TrimSpace(fmt.Sprint(emailClaim))
- }
- c.Set("user_id", userID)
- c.Set("email", email)
- c.Next()
- return
- }
- }
- tokenErr = "Invalid token claims"
- } else if tokenErr == "" {
- tokenErr = "Invalid token"
- }
- }
-
- if sessionVerifier != nil {
- if userID, email, ok := sessionVerifier.resolveUser(c); ok {
- c.Set("user_id", userID)
- c.Set("email", email)
- c.Next()
- return
- }
+ userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
+ if ok {
+ setAuthenticatedContext(c, userID, email)
+ c.Next()
+ return
}
if tokenErr != "" {
@@ -121,6 +152,38 @@ func Auth(jwtSecret string) gin.HandlerFunc {
}
}
+// OptionalAuth resolves identity when a valid credential is present but never
+// rejects the request — anonymous callers simply get no user context. Read
+// endpoints hang off this so the public site can browse approved resources.
+func OptionalAuth(jwtSecret string) gin.HandlerFunc {
+ sessionVerifier := newBetterAuthSessionVerifier()
+
+ return func(c *gin.Context) {
+ if userID, email, _, _, ok := resolveRequestUser(c, jwtSecret, sessionVerifier); ok {
+ setAuthenticatedContext(c, userID, email)
+ }
+ c.Next()
+ }
+}
+
+// RequireAdmin rejects requests whose resolved user is not a platform admin.
+// Must run after Auth (or OptionalAuth) so user_id/is_admin are populated.
+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.Abort()
+ return
+ }
+ if !c.GetBool("is_admin") {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
+
type betterAuthSessionVerifier struct {
internalURL string
internalToken string
diff --git a/app/backend/migrations_goose/20260924000000_public_platform.sql b/app/backend/migrations_goose/20260924000000_public_platform.sql
new file mode 100644
index 0000000..3dedc2d
--- /dev/null
+++ b/app/backend/migrations_goose/20260924000000_public_platform.sql
@@ -0,0 +1,16 @@
+-- +goose Up
+-- Public platform model: projects are browsable without a session once the
+-- admin approves them; service_templates gains ownership for user templates.
+ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS is_approved boolean NOT NULL DEFAULT false;
+
+-- Projects that already exist stay live; only newly created ones enter the
+-- approval queue.
+UPDATE public.projects SET is_approved = true;
+
+ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS owner_id uuid;
+CREATE INDEX IF NOT EXISTS idx_service_templates_owner ON public.service_templates(owner_id);
+
+-- +goose Down
+ALTER TABLE public.projects DROP COLUMN IF EXISTS is_approved;
+DROP INDEX IF EXISTS public.idx_service_templates_owner;
+ALTER TABLE public.service_templates DROP COLUMN IF EXISTS owner_id;
diff --git a/app/backend/sqlc/queries/databases.sql b/app/backend/sqlc/queries/databases.sql
index a5a6f21..7bd1bd7 100644
--- a/app/backend/sqlc/queries/databases.sql
+++ b/app/backend/sqlc/queries/databases.sql
@@ -1,11 +1,26 @@
-- name: ListDatabaseServicesByUser :many
-SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC;
+-- name: ListAllDatabaseServices :many
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+FROM database_services
+ORDER BY created_at DESC;
+
+-- name: GetDatabaseServiceByID :one
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+FROM database_services
+WHERE id = $1;
+
+-- name: GetDatabaseServiceOwnerID :one
+SELECT user_id
+FROM database_services
+WHERE id = $1;
+
-- name: GetDatabaseServiceByIDAndUser :one
-SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
+SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2;
diff --git a/app/backend/sqlc/queries/projects.sql b/app/backend/sqlc/queries/projects.sql
index 5ac83a8..88a949c 100644
--- a/app/backend/sqlc/queries/projects.sql
+++ b/app/backend/sqlc/queries/projects.sql
@@ -1,9 +1,14 @@
+-- Visibility model: a project is readable when it is approved for public
+-- display, when the caller owns or is a member of it, or when the caller is a
+-- platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
+
-- name: ListProjectsWithStatsByUser :many
SELECT
p.id,
p.name,
p.description,
p.owner_id,
+ p.is_approved,
p.created_at,
p.updated_at,
COUNT(DISTINCT s.id)::bigint AS service_count,
@@ -21,17 +26,22 @@ FROM projects p
LEFT JOIN services s ON s.project_id = p.id
LEFT JOIN deployments d ON d.service_id = s.id
WHERE
- (p.owner_id = sqlc.arg(user_id) OR EXISTS (
- SELECT 1
- FROM project_members pm
- WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
- ))
+ (
+ p.is_approved
+ OR p.owner_id = sqlc.arg(user_id)
+ OR sqlc.arg(is_admin)::bool
+ OR EXISTS (
+ SELECT 1
+ FROM project_members pm
+ WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
+ )
+ )
AND (
sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%')
)
-GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
+GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
ORDER BY p.updated_at DESC
LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
@@ -39,11 +49,16 @@ LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
SELECT COUNT(*)::bigint AS total
FROM projects p
WHERE
- (p.owner_id = sqlc.arg(user_id) OR EXISTS (
- SELECT 1
- FROM project_members pm
- WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
- ))
+ (
+ p.is_approved
+ OR p.owner_id = sqlc.arg(user_id)
+ OR sqlc.arg(is_admin)::bool
+ OR EXISTS (
+ SELECT 1
+ FROM project_members pm
+ WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
+ )
+ )
AND (
sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
@@ -51,11 +66,13 @@ WHERE
);
-- name: GetProjectByIDForUser :one
-SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
+SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.id = sqlc.arg(project_id)
AND (
- p.owner_id = sqlc.arg(user_id)
+ p.is_approved
+ OR p.owner_id = sqlc.arg(user_id)
+ OR sqlc.arg(is_admin)::bool
OR EXISTS (
SELECT 1
FROM project_members pm
@@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id)
-- name: CreateProject :one
INSERT INTO projects (name, description, owner_id)
VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id))
-RETURNING id, name, description, owner_id, created_at, updated_at;
+RETURNING id, name, description, owner_id, is_approved, created_at, updated_at;
-- name: InsertProjectEnvironment :exec
INSERT INTO environments (name, project_id)
@@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id));
-- name: GetProjectRoleForUser :one
SELECT (CASE
- WHEN p.owner_id = sqlc.arg(user_id) THEN 'owner'
+ WHEN p.owner_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool THEN 'owner'
ELSE COALESCE(pm.role, '')
END)::text AS role
FROM projects p
@@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id);
-- name: DeleteProjectByID :execrows
DELETE FROM projects
WHERE id = sqlc.arg(project_id);
+
+-- name: SetProjectApproved :execrows
+UPDATE projects
+SET is_approved = sqlc.arg(is_approved), updated_at = NOW()
+WHERE id = sqlc.arg(project_id);
+
+-- name: ListPendingProjects :many
+SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
+FROM projects p
+WHERE p.is_approved = false
+ORDER BY p.created_at DESC;
diff --git a/app/backend/sqlc/queries/templates.sql b/app/backend/sqlc/queries/templates.sql
index 1bafe20..25d2de4 100644
--- a/app/backend/sqlc/queries/templates.sql
+++ b/app/backend/sqlc/queries/templates.sql
@@ -1,19 +1,76 @@
--- name: ListServiceTemplates :many
-SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
+-- name: ListServiceTemplatesForUser :many
+SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
+WHERE is_official = true OR owner_id = sqlc.narg(owner_id)
ORDER BY is_official DESC, name ASC;
--- name: ListServiceTemplatesByCategory :many
-SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
+-- name: ListServiceTemplatesByCategoryForUser :many
+SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
-WHERE category = $1
+WHERE category = sqlc.arg(category)
+ AND (is_official = true OR owner_id = sqlc.narg(owner_id))
ORDER BY is_official DESC, name ASC;
-- name: GetServiceTemplateByID :one
-SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
+SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
WHERE id = $1;
+-- name: UpsertServiceTemplate :exec
+INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
+VALUES (
+ sqlc.arg(id),
+ sqlc.arg(name),
+ sqlc.narg(description),
+ sqlc.arg(category),
+ sqlc.narg(logo),
+ sqlc.arg(config),
+ sqlc.narg(variables),
+ sqlc.arg(is_official)
+)
+ON CONFLICT (id) DO UPDATE
+SET name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ category = EXCLUDED.category,
+ logo = EXCLUDED.logo,
+ config = EXCLUDED.config,
+ variables = EXCLUDED.variables,
+ is_official = EXCLUDED.is_official,
+ updated_at = NOW();
+
+-- name: CreateUserTemplate :exec
+INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
+VALUES (
+ sqlc.arg(id),
+ sqlc.arg(name),
+ sqlc.narg(description),
+ sqlc.arg(category),
+ sqlc.narg(logo),
+ sqlc.arg(config),
+ sqlc.narg(variables),
+ false,
+ sqlc.arg(owner_id)
+);
+
+-- name: UpdateUserTemplate :execrows
+UPDATE service_templates
+SET name = sqlc.arg(name),
+ description = sqlc.narg(description),
+ category = sqlc.arg(category),
+ logo = sqlc.narg(logo),
+ config = sqlc.arg(config),
+ variables = sqlc.narg(variables),
+ updated_at = NOW()
+WHERE id = sqlc.arg(id)
+ AND owner_id = sqlc.arg(owner_id)
+ AND is_official = false;
+
+-- name: DeleteUserTemplate :execrows
+DELETE FROM service_templates
+WHERE id = sqlc.arg(id)
+ AND owner_id = sqlc.arg(owner_id)
+ AND is_official = false;
+
-- name: GetProjectOwnerID :one
SELECT owner_id
FROM projects
diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql
index 67a38c7..a4e69bf 100644
--- a/app/backend/sqlc/schema.sql
+++ b/app/backend/sqlc/schema.sql
@@ -3,6 +3,7 @@ CREATE TABLE projects (
name VARCHAR(255) NOT NULL,
description TEXT,
owner_id UUID NOT NULL,
+ is_approved BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -82,6 +83,7 @@ CREATE TABLE service_templates (
config JSONB NOT NULL,
variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false,
+ owner_id UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json
index 92c9a30..06c1088 100644
--- a/app/frontend/package-lock.json
+++ b/app/frontend/package-lock.json
@@ -32,6 +32,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
+ "@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"axios": "^1.7.9",
@@ -40,7 +41,9 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.2.6",
"lucide-react": "^0.563.0",
+ "marked": "^18.0.13",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -3719,6 +3722,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3763,6 +3775,12 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -5205,6 +5223,15 @@
"license": "MIT",
"peer": true
},
+ "node_modules/dompurify": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
+ "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6577,6 +6604,18 @@
"node": ">=10"
}
},
+ "node_modules/marked": {
+ "version": "18.0.13",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz",
+ "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/app/frontend/package.json b/app/frontend/package.json
index b1dc647..73c5952 100644
--- a/app/frontend/package.json
+++ b/app/frontend/package.json
@@ -7,6 +7,9 @@
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
+ "predev": "node scripts/generate-docs-snapshot.mjs",
+ "prebuild": "node scripts/generate-docs-snapshot.mjs",
+ "generate:docs": "node scripts/generate-docs-snapshot.mjs",
"generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts",
"lint": "eslint .",
"preview": "vite preview",
@@ -40,6 +43,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
+ "@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"axios": "^1.7.9",
@@ -48,7 +52,9 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.2.6",
"lucide-react": "^0.563.0",
+ "marked": "^18.0.13",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/app/frontend/scripts/generate-docs-snapshot.mjs b/app/frontend/scripts/generate-docs-snapshot.mjs
new file mode 100644
index 0000000..6534cee
--- /dev/null
+++ b/app/frontend/scripts/generate-docs-snapshot.mjs
@@ -0,0 +1,55 @@
+// Bundles the repository docs/ tree into a generated TS module so the docs
+// browser renders instantly and offline. The GitHub sync layer refreshes it
+// at runtime when network is available.
+import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..');
+const docsDir = join(root, 'docs');
+const outFile = join(root, 'app/frontend/src/generated/docs-snapshot.ts');
+
+// Directories that are internal working notes rather than user documentation.
+const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
+
+function walk(dir) {
+ const entries = [];
+ for (const name of readdirSync(dir)) {
+ const full = join(dir, name);
+ const stat = statSync(full);
+ if (stat.isDirectory()) {
+ if (!SKIP_DIRS.has(name)) entries.push(...walk(full));
+ } else if (name.toLowerCase().endsWith('.md')) {
+ entries.push(full);
+ }
+ }
+ return entries;
+}
+
+function titleOf(markdown, fallback) {
+ const match = markdown.match(/^#\s+(.+)$/m);
+ if (match) return match[1].replace(/[#*`]/g, '').trim();
+ return fallback
+ .replace(/\.md$/i, '')
+ .replace(/[-_]+/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+const files = walk(docsDir).sort();
+const docs = files.map((full) => {
+ const rel = relative(docsDir, full).replace(/\\/g, '/');
+ const markdown = readFileSync(full, 'utf8');
+ const section = rel.includes('/') ? rel.split('/')[0] : 'docs';
+ return {
+ path: rel,
+ section,
+ title: titleOf(markdown, rel.split('/').pop() ?? rel),
+ markdown,
+ };
+});
+
+const banner = `/**\n * Auto-generated by scripts/generate-docs-snapshot.mjs.\n * Bundled copy of the repository docs tree — the offline fallback for the\n * docs browser. Regenerate with \`npm run generate:docs\`.\n */\n\nexport type BundledDoc = {\n path: string;\n section: string;\n title: string;\n markdown: string;\n};\n\n`;
+
+mkdirSync(join(outFile, '..'), { recursive: true });
+writeFileSync(outFile, `${banner}export const bundledDocs: BundledDoc[] = ${JSON.stringify(docs, null, 2)};\n`);
+console.log(`docs snapshot: ${docs.length} files → ${relative(root, outFile)}`);
diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx
index 2a724a0..ef3444c 100644
--- a/app/frontend/src/app/App.tsx
+++ b/app/frontend/src/app/App.tsx
@@ -1,4 +1,6 @@
-import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
+import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
@@ -7,6 +9,8 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
+import { AdminPage } from '@/features/admin/pages/AdminPage';
+import { LandingPage } from '@/features/landing/LandingPage';
import {
DocsPage,
PeoplePage,
@@ -19,12 +23,17 @@ import {
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
+import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components';
+function useDemoMode() {
+ const location = useLocation();
+ return new URLSearchParams(location.search).get('demo') === '1';
+}
+
function AuthRequired() {
const location = useLocation();
- const searchParams = new URLSearchParams(location.search);
- const isDemoMode = searchParams.get('demo') === '1';
+ const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
if (isDemoMode) {
@@ -47,38 +56,98 @@ function AuthRequired() {
return
+ This area is restricted to the platform administrator. +
+No projects yet
- Create your first project to start deploying services with visual topology management. + {signedIn + ? 'Create your first project to start deploying services with visual topology management.' + : 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
-Projects organize your services and provide a visual canvas for topology management.
++ New projects stay private until a platform admin approves them for public listing. +
Failed to load usage data
Sign in to view build activity and capacity.
++ Node registration and token issuance are admin operations. {signedIn ? 'Your account does not have admin rights.' : 'Sign in as an admin to manage nodes.'} +
+ )}{profileStatus.label}
-- {profileQuery.data ? `${profileQuery.data.name} authenticated` : 'GET /user/profile'} -
-{projectStatus.label}
-- {projectsQuery.data ? `${projectsQuery.data.length} projects` : 'GET /projects'} -
-{templateStatus.label}
-- {templatesQuery.data ? `${templatesQuery.data.length} templates` : 'GET /templates'} -
-{buildStatus.label}
-- {buildsQuery.data ? `${buildsQuery.data.total} builds` : 'GET /builds'} -
-Source files and documentation
-docs/api/openapi.yaml
-app/frontend/src/generated/api-types.ts
-README.md • DOCKER_SETUP.md • docs/guides/
-docs/references/dashboard.png • projects.png
-Development and build scripts
-
-{`# Regenerate frontend API types
-npm --prefix app/frontend run generate:api
-
-# Frontend type-check + build
-npm --prefix app/frontend run build:check
-
-# Backend API tests
-cd app/backend && go test ./internal/api/...
-
-# Build remote node agent
-cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
-
- {getApiBaseUrl()}
-+ {databases.length} {databases.length === 1 ? 'database' : 'databases'} +
+ {signedIn && !isDemoMode ? ( +No managed databases yet.
-- Create one via Add Service → Database in a project workspace. -
+ {signedIn ? ( ++ Sign in to provision a managed database. +
+ )}Backups
+ {signedIn ? (Provision a managed database service.
+Bind to service
{alert.message ?? alert.id}
@@ -2233,14 +2263,16 @@ export function HighAvailabilityPage() { {alert.severity ?? 'info'} · since {alert.starts_at ? formatRelative(alert.starts_at) : '—'}+
{policyForm.serviceId}
{policy.service_id}
@@ -2385,6 +2417,8 @@ export function HighAvailabilityPage() {{result.check_id}
@@ -2473,6 +2509,8 @@ const severityColor: Record
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
index dbd79c7..43bd837 100644
--- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
+++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
@@ -36,6 +36,8 @@ import {
type CronJobEntity,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
+import { useAuthSession } from '@/lib/use-auth-session';
+import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
@@ -70,16 +72,18 @@ import {
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings';
-const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
+// Sections marked manageOnly are hidden from anonymous visitors — they
+// expose authenticated or owner-scoped data (logs, variables, exec).
+const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity; manageOnly?: boolean }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
- { key: 'logs', label: 'Logs', icon: FileText },
+ { key: 'logs', label: 'Logs', icon: FileText, manageOnly: true },
{ key: 'config', label: 'Config', icon: Sliders },
- { key: 'variables', label: 'Variables', icon: KeyRound },
+ { key: 'variables', label: 'Variables', icon: KeyRound, manageOnly: true },
{ key: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'scaling', label: 'Scaling', icon: Layers },
- { key: 'console', label: 'Console', icon: Terminal },
- { key: 'settings', label: 'Settings', icon: Settings },
+ { key: 'console', label: 'Console', icon: Terminal, manageOnly: true },
+ { key: 'settings', label: 'Settings', icon: Settings, manageOnly: true },
];
function metricStatus(percent: number): 'good' | 'average' | 'warning' {
@@ -120,6 +124,15 @@ export function ServiceDetailPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
+ const sessionQuery = useAuthSession({ enabled: !isDemoMode });
+ const signedIn = isDemoMode || Boolean(sessionQuery.data);
+ const profileQuery = useQuery({
+ queryKey: ['user-profile'],
+ queryFn: getCurrentUserProfile,
+ enabled: signedIn && !isDemoMode,
+ retry: false,
+ });
+ const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [activeSection, setActiveSection] = useState
Scheduled commands executed inside the service container
Ephemeral per-branch deployments of this service
Replicas
{scalingStateQuery.data?.CurrentReplicas ?? @@ -1609,19 +1635,19 @@ export function ServiceDetailPage() {
Policy
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
Autoscaling
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
Last action
{scalingStateQuery.data?.LastScaleDirection ?? '—'} @@ -1639,15 +1665,17 @@ export function ServiceDetailPage() { max={20} value={scaleReplicas} onChange={(e) => setScaleReplicas(e.target.value)} - className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]" + className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]" placeholder="e.g. 3" />
$ {entry.command}
@@ -1878,25 +1910,25 @@ export function ServiceDetailPage() { {networkForm === null ? (Port
{service.port || '—'}
Domain
{service.domain || '—'}
Replicas
{service.replicas ?? 1}
Restart
{service.restartPolicy || 'unless-stopped'}
Published on the host; routed via Traefik when a domain is set
Deploy services from pre-configured templates
{template.name}
- {template.isOfficial && ( + {template.isOfficial ? ({template.description}
+ Sign in to deploy this template into one of your projects. +
+ + Sign in+ JSON definition — name, category, config and variables. +
+