This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/extensions.go
Jeffrey Smith 57ccf19efb
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m18s
CI/CD / test-sqlite (push) Successful in 2m37s
CI/CD / build-and-deploy (push) Successful in 1m26s
Feat triggers v0.2.2 (#6)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-26 22:47:31 +00:00

436 lines
12 KiB
Go

package handlers
import (
"database/sql"
"encoding/json"
"log"
"github.com/gin-gonic/gin"
"strings"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/triggers"
)
// ExtensionHandler serves extension management endpoints.
// v0.28.7: Backed by PackageStore (packages table) instead of ExtensionStore.
type ExtensionHandler struct {
stores store.Stores
}
func NewExtensionHandler(stores store.Stores) *ExtensionHandler {
return &ExtensionHandler{stores: stores}
}
// validTiers is the set of accepted extension tier values.
var validTiers = map[string]bool{
models.ExtTierBrowser: true,
models.ExtTierStarlark: true,
models.ExtTierSidecar: true,
}
// ── User endpoints ──────────────────────────────
// ListUserExtensions returns all enabled extensions for the current user,
// with user-specific settings and enabled state merged in.
// GET /api/v1/extensions
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
userID := c.GetString("user_id")
tier := c.Query("tier")
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
if tier != "" {
filtered := make([]store.UserPackage, 0)
for _, p := range pkgs {
if p.Tier == tier {
filtered = append(filtered, p)
}
}
pkgs = filtered
}
c.JSON(200, gin.H{"data": pkgs})
}
// UpdateUserExtensionSettings saves per-user settings for an extension.
// POST /api/v1/extensions/:id/settings
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
userID := c.GetString("user_id")
pkgID := c.Param("id")
// Verify package exists
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
// System packages can't be disabled by users
var body struct {
Settings json.RawMessage `json:"settings"`
IsEnabled *bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
enabled := true
if body.IsEnabled != nil {
if pkg.IsSystem && !*body.IsEnabled {
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
return
}
enabled = *body.IsEnabled
}
settings := json.RawMessage("{}")
if body.Settings != nil {
// v0.2.0: enforce user_overridable — strip locked keys before saving
schema := store.ParseSettingsSchema(pkg.Manifest)
if len(schema) > 0 {
var incoming map[string]any
if json.Unmarshal(body.Settings, &incoming) == nil && len(incoming) > 0 {
filtered, rejected := store.FilterOverridableKeys(incoming, schema)
if len(filtered) == 0 && len(rejected) > 0 {
c.JSON(400, gin.H{"error": "these settings cannot be overridden: " + strings.Join(rejected, ", ")})
return
}
if b, err := json.Marshal(filtered); err == nil {
settings = json.RawMessage(b)
} else {
settings = body.Settings
}
} else {
settings = body.Settings
}
} else {
settings = body.Settings
}
}
pus := &store.PackageUserSettings{
PackageID: pkgID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
}
if err := h.stores.Packages.SetUserSettings(c.Request.Context(), pus); err != nil {
c.JSON(500, gin.H{"error": "failed to save settings"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// ── Admin endpoints ─────────────────────────────
// AdminListExtensions returns all extension-type packages.
// GET /api/v1/admin/extensions
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
pkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "extension")
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
// Also include 'full' type packages
fullPkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "full")
if err == nil {
pkgs = append(pkgs, fullPkgs...)
}
if pkgs == nil {
pkgs = []store.PackageRegistration{}
}
c.JSON(200, gin.H{"data": pkgs})
}
// AdminInstallExtension installs an extension from a JSON body (legacy path).
// POST /api/v1/admin/extensions
// New installs should use POST /admin/packages/install with a .pkg archive.
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
userID := c.GetString("user_id")
var body struct {
ExtID string `json:"ext_id" binding:"required"`
Name string `json:"name" binding:"required"`
Version string `json:"version"`
Tier string `json:"tier"`
Description string `json:"description"`
Author string `json:"author"`
Manifest json.RawMessage `json:"manifest"`
IsSystem bool `json:"is_system"`
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request: " + err.Error()})
return
}
// Defaults
if body.Version == "" {
body.Version = "0.0.0"
}
if body.Tier == "" {
body.Tier = models.ExtTierBrowser
}
if body.Manifest == nil {
body.Manifest = json.RawMessage("{}")
}
// Validate tier
if !validTiers[body.Tier] {
c.JSON(400, gin.H{"error": "invalid tier: must be browser, starlark, or sidecar"})
return
}
// Check for duplicate (ext_id is now the package ID)
existing, _ := h.stores.Packages.Get(c.Request.Context(), body.ExtID)
if existing != nil {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
}
// Parse manifest into map
var manifestMap map[string]any
if err := json.Unmarshal(body.Manifest, &manifestMap); err != nil {
manifestMap = map[string]any{}
}
pkg := &store.PackageRegistration{
ID: body.ExtID,
Title: body.Name,
Type: "extension",
Version: body.Version,
Description: body.Description,
Author: body.Author,
Tier: body.Tier,
IsSystem: body.IsSystem,
Scope: "global",
Manifest: manifestMap,
Enabled: body.IsEnabled,
Source: "extension",
InstalledBy: &userID,
}
if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil {
if database.IsUniqueViolation(err) {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
}
c.JSON(500, gin.H{"error": "failed to install extension"})
return
}
// v0.29.0: Parse manifest permissions and declare them.
// If permissions are declared, package moves to pending_review.
SyncManifestPermissions(c, h.stores, pkg.ID, manifestMap)
// v0.29.2: Create namespaced DB tables declared in the manifest.
if tables, ok := ParseDBTables(manifestMap); ok {
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkg.ID, tables); err != nil {
log.Printf("[ext-db] schema create failed for %s: %v", pkg.ID, err)
}
}
// v0.2.2: Sync triggers from manifest
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkg.ID, manifestMap)
c.JSON(201, gin.H{"data": pkg})
}
// AdminUpdateExtension updates an extension-type package.
// PUT /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
var body struct {
Name *string `json:"name"`
Version *string `json:"version"`
Description *string `json:"description"`
Author *string `json:"author"`
IsSystem *bool `json:"is_system"`
IsEnabled *bool `json:"is_enabled"`
Manifest *json.RawMessage `json:"manifest"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
if body.Name != nil {
pkg.Title = *body.Name
}
if body.Version != nil {
pkg.Version = *body.Version
}
if body.Description != nil {
pkg.Description = *body.Description
}
if body.Author != nil {
pkg.Author = *body.Author
}
if body.IsSystem != nil {
pkg.IsSystem = *body.IsSystem
}
if body.IsEnabled != nil {
pkg.Enabled = *body.IsEnabled
}
if body.Manifest != nil {
var manifestMap map[string]any
if err := json.Unmarshal(*body.Manifest, &manifestMap); err == nil {
pkg.Manifest = manifestMap
}
}
if err := h.stores.Packages.Update(c.Request.Context(), id, pkg); err != nil {
c.JSON(500, gin.H{"error": "failed to update extension"})
return
}
c.JSON(200, gin.H{"data": pkg})
}
// ServeExtensionAsset serves browser extension JS files.
// GET /api/v1/extensions/:id/assets/*path
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
pkgID := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if !pkg.Enabled {
c.JSON(404, gin.H{"error": "extension not enabled"})
return
}
// Extract inline script from manifest
manifestBytes := marshalManifest(pkg.Manifest)
var manifest map[string]json.RawMessage
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
c.JSON(500, gin.H{"error": "invalid manifest"})
return
}
scriptRaw, ok := manifest["_script"]
if !ok {
c.JSON(404, gin.H{"error": "no script in manifest"})
return
}
var script string
if err := json.Unmarshal(scriptRaw, &script); err != nil {
c.JSON(500, gin.H{"error": "invalid script in manifest"})
return
}
c.Header("Content-Type", "application/javascript; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.String(200, script)
}
// GetExtensionManifest returns the manifest for a specific extension.
// GET /api/v1/extensions/:id/manifest
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
pkgID := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
c.JSON(200, gin.H{"data": pkg.Manifest})
}
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
// GET /api/v1/extensions/tools
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
userID := c.GetString("user_id")
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
var toolSchemas []json.RawMessage
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
continue
}
manifestBytes := marshalManifest(pkg.Manifest)
var manifest struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
log.Printf("[extensions] failed to parse manifest for %s: %v", pkg.ID, err)
continue
}
toolSchemas = append(toolSchemas, manifest.Tools...)
}
if toolSchemas == nil {
toolSchemas = make([]json.RawMessage, 0)
}
c.JSON(200, gin.H{"data": toolSchemas})
}
// AdminUninstallExtension removes an extension-type package.
// DELETE /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
// v0.29.2: Drop namespaced DB tables before removing the package record.
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
}
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
if err == sql.ErrNoRows {
c.JSON(400, gin.H{"error": "core packages cannot be deleted"})
return
}
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// marshalManifest converts map[string]any → []byte for struct unmarshaling.
// PackageRegistration.Manifest is map[string]any (from JSONB scan), but
// several callsites need to unmarshal into typed structs.
func marshalManifest(m map[string]any) []byte {
if m == nil {
return []byte("{}")
}
b, err := json.Marshal(m)
if err != nil {
return []byte("{}")
}
return b
}