package handlers // user_packages.go — v0.30.0 CS5 // // Non-admin package management. Users install personal-scoped packages; // team admins install team-scoped packages. // // Restrictions for non-global scope: // - Tier: browser only (no starlark/sidecar) // - Permissions: only notifications.send allowed // - Prevents privilege escalation through user-installed packages import ( "archive/zip" "encoding/json" "io" "log" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/store" ) // allowedNonGlobalPermissions is the set of permissions that non-admin // (team/personal scope) packages may declare. var allowedNonGlobalPermissions = map[string]bool{ "notifications.send": true, } // UserPackageHandler manages user and team scoped package operations. type UserPackageHandler struct { stores store.Stores packagesDir string } // NewUserPackageHandler creates a new user package handler. func NewUserPackageHandler(s store.Stores, packagesDir string) *UserPackageHandler { return &UserPackageHandler{stores: s, packagesDir: packagesDir} } // ListVisiblePackages returns packages visible to the current user // (global + user's teams + personal). // GET /api/v1/packages func (h *UserPackageHandler) ListVisiblePackages(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } pkgs, err := h.stores.Packages.ListVisiblePackages(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"}) return } if pkgs == nil { pkgs = []store.PackageRegistration{} } c.JSON(http.StatusOK, gin.H{"data": pkgs}) } // InstallPersonalPackage installs a package with personal scope. // POST /api/v1/packages/install func (h *UserPackageHandler) InstallPersonalPackage(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } h.installScoped(c, "personal", "", userID) } // InstallTeamPackage installs a package with team scope. // POST /api/v1/teams/:teamId/packages/install func (h *UserPackageHandler) InstallTeamPackage(c *gin.Context) { userID := c.GetString("user_id") teamID := c.Param("teamId") if userID == "" || teamID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing user or team context"}) return } h.installScoped(c, "team", teamID, userID) } // DeletePersonalPackage deletes a personal-scoped package owned by the user. // DELETE /api/v1/packages/:id func (h *UserPackageHandler) DeletePersonalPackage(c *gin.Context) { userID := c.GetString("user_id") id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } if pkg.Scope != "personal" || pkg.InstalledBy == nil || *pkg.InstalledBy != userID { c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own personal packages"}) return } if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"}) return } // Clean up assets if h.packagesDir != "" { os.RemoveAll(filepath.Join(h.packagesDir, id)) } c.JSON(http.StatusOK, gin.H{"deleted": id}) } // DeleteTeamPackage deletes a team-scoped package. // DELETE /api/v1/teams/:teamId/packages/:id func (h *UserPackageHandler) DeleteTeamPackage(c *gin.Context) { teamID := c.Param("teamId") id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } if pkg.Scope != "team" || pkg.TeamID == nil || *pkg.TeamID != teamID { c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"}) return } if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"}) return } if h.packagesDir != "" { os.RemoveAll(filepath.Join(h.packagesDir, id)) } c.JSON(http.StatusOK, gin.H{"deleted": id}) } // installScoped handles the common install flow for team/personal packages. func (h *UserPackageHandler) installScoped(c *gin.Context, scope, teamID, userID string) { file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) return } defer file.Close() validExt := strings.HasSuffix(header.Filename, ".pkg") || strings.HasSuffix(header.Filename, ".surface") || strings.HasSuffix(header.Filename, ".zip") if !validExt { c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"}) return } if header.Size > 50*1024*1024 { c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"}) return } // Read into temp file tmpFile, err := os.CreateTemp("", "userpkg-*.zip") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return } tmpPath := tmpFile.Name() defer os.Remove(tmpPath) if _, err := io.Copy(tmpFile, file); err != nil { tmpFile.Close() c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"}) return } tmpFile.Close() // Open as zip and extract manifest zr, err := zip.OpenReader(tmpPath) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) return } defer zr.Close() var manifest map[string]any for _, f := range zr.File { name := filepath.Base(f.Name) if name == "manifest.json" && !f.FileInfo().IsDir() { rc, err := f.Open() if err != nil { continue } data, _ := io.ReadAll(rc) rc.Close() json.Unmarshal(data, &manifest) break } } if manifest == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"}) return } pkgID, _ := manifest["id"].(string) title, _ := manifest["title"].(string) if pkgID == "" || title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"}) return } if !validPackageID.MatchString(pkgID) { c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug"}) return } // Enforce tier restriction: browser only for non-global tier, _ := manifest["tier"].(string) if tier == "" { tier = "browser" } if tier != "browser" { c.JSON(http.StatusForbidden, gin.H{ "error": "only browser-tier packages can be installed at team/personal scope", }) return } // Enforce permission restriction if permsRaw, ok := manifest["permissions"].([]any); ok { for _, p := range permsRaw { if perm, ok := p.(string); ok { if !allowedNonGlobalPermissions[perm] { c.JSON(http.StatusForbidden, gin.H{ "error": "permission " + perm + " is not allowed for " + scope + "-scoped packages", }) return } } } } // Check for conflicts existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID) if existing != nil && existing.Source == "core" { c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package"}) return } // Extract static assets if h.packagesDir != "" { destDir := filepath.Join(h.packagesDir, pkgID) os.MkdirAll(destDir, 0755) for _, f := range zr.File { if f.FileInfo().IsDir() { continue } relPath := extractableRelPath(f.Name) if relPath == "" { continue } destPath := filepath.Join(destDir, relPath) if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) { continue } os.MkdirAll(filepath.Dir(destPath), 0755) rc, err := f.Open() if err != nil { continue } out, err := os.Create(destPath) if err != nil { rc.Close() continue } io.Copy(out, rc) out.Close() rc.Close() } } version, _ := manifest["version"].(string) if version == "" { version = "0.0.0" } pkgType, _ := manifest["type"].(string) if pkgType == "" { pkgType = "surface" } description, _ := manifest["description"].(string) author, _ := manifest["author"].(string) // Create the package with scope pkg := &store.PackageRegistration{ ID: pkgID, Title: title, Type: pkgType, Version: version, Description: description, Author: author, Tier: tier, IsSystem: false, Scope: scope, Enabled: true, Status: "active", Source: "extension", Manifest: manifest, } if teamID != "" { pkg.TeamID = &teamID } pkg.InstalledBy = &userID if existing != nil { // Update existing if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package"}) return } } else { // Create new if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create package"}) return } } log.Printf("[packages] installed %s-scoped package %s by user %s", scope, pkgID, userID) c.JSON(http.StatusOK, gin.H{ "id": pkgID, "title": title, "type": pkgType, "version": version, "scope": scope, "source": "extension", }) }