Changeset 0.28.7 (#193)
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// ExtensionHandler serves extension management endpoints.
|
||||
// v0.28.7: Backed by PackageStore (packages table) instead of ExtensionStore.
|
||||
type ExtensionHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
@@ -35,45 +36,41 @@ var validTiers = map[string]bool{
|
||||
// GET /api/v1/extensions
|
||||
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
tier := c.Query("tier") // optional filter
|
||||
tier := c.Query("tier")
|
||||
|
||||
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
|
||||
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([]models.UserExtension, 0)
|
||||
for _, e := range exts {
|
||||
if e.Tier == tier {
|
||||
filtered = append(filtered, e)
|
||||
filtered := make([]store.UserPackage, 0)
|
||||
for _, p := range pkgs {
|
||||
if p.Tier == tier {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
exts = filtered
|
||||
pkgs = filtered
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"data": exts})
|
||||
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")
|
||||
extID := c.Param("id")
|
||||
pkgID := c.Param("id")
|
||||
|
||||
// Verify extension exists
|
||||
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
|
||||
if err == sql.ErrNoRows {
|
||||
// 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
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
// System extensions can't be disabled by users
|
||||
// System packages can't be disabled by users
|
||||
var body struct {
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
IsEnabled *bool `json:"is_enabled"`
|
||||
@@ -85,7 +82,7 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
||||
|
||||
enabled := true
|
||||
if body.IsEnabled != nil {
|
||||
if ext.IsSystem && !*body.IsEnabled {
|
||||
if pkg.IsSystem && !*body.IsEnabled {
|
||||
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
|
||||
return
|
||||
}
|
||||
@@ -97,13 +94,13 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
||||
settings = body.Settings
|
||||
}
|
||||
|
||||
eus := &models.ExtensionUserSettings{
|
||||
ExtensionID: extID,
|
||||
UserID: userID,
|
||||
Settings: settings,
|
||||
IsEnabled: enabled,
|
||||
pus := &store.PackageUserSettings{
|
||||
PackageID: pkgID,
|
||||
UserID: userID,
|
||||
Settings: settings,
|
||||
IsEnabled: enabled,
|
||||
}
|
||||
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
|
||||
if err := h.stores.Packages.SetUserSettings(c.Request.Context(), pus); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to save settings"})
|
||||
return
|
||||
}
|
||||
@@ -113,19 +110,28 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
||||
|
||||
// ── Admin endpoints ─────────────────────────────
|
||||
|
||||
// AdminListExtensions returns all extensions (enabled and disabled).
|
||||
// AdminListExtensions returns all extension-type packages.
|
||||
// GET /api/v1/admin/extensions
|
||||
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
|
||||
exts, err := h.stores.Extensions.ListAll(c.Request.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
|
||||
}
|
||||
c.JSON(200, gin.H{"data": exts})
|
||||
// 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 manifest.
|
||||
// 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")
|
||||
|
||||
@@ -162,32 +168,36 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate ext_id
|
||||
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
c.JSON(500, gin.H{"error": "failed to check existing extension"})
|
||||
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
|
||||
}
|
||||
|
||||
ext := &models.Extension{
|
||||
ExtID: body.ExtID,
|
||||
Name: body.Name,
|
||||
// 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,
|
||||
Tier: body.Tier,
|
||||
Description: body.Description,
|
||||
Author: body.Author,
|
||||
Manifest: body.Manifest,
|
||||
Tier: body.Tier,
|
||||
IsSystem: body.IsSystem,
|
||||
IsEnabled: body.IsEnabled,
|
||||
Scope: models.ScopeGlobal,
|
||||
Scope: "global",
|
||||
Manifest: manifestMap,
|
||||
Enabled: body.IsEnabled,
|
||||
Source: "extension",
|
||||
InstalledBy: &userID,
|
||||
}
|
||||
|
||||
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
|
||||
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
|
||||
@@ -196,23 +206,19 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(201, gin.H{"data": ext})
|
||||
c.JSON(201, gin.H{"data": pkg})
|
||||
}
|
||||
|
||||
// AdminUpdateExtension updates an extension's config.
|
||||
// AdminUpdateExtension updates an extension-type package.
|
||||
// PUT /api/v1/admin/extensions/:id
|
||||
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
||||
if err == sql.ErrNoRows {
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -229,62 +235,58 @@ func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
ext.Name = *body.Name
|
||||
pkg.Title = *body.Name
|
||||
}
|
||||
if body.Version != nil {
|
||||
ext.Version = *body.Version
|
||||
pkg.Version = *body.Version
|
||||
}
|
||||
if body.Description != nil {
|
||||
ext.Description = *body.Description
|
||||
pkg.Description = *body.Description
|
||||
}
|
||||
if body.Author != nil {
|
||||
ext.Author = *body.Author
|
||||
pkg.Author = *body.Author
|
||||
}
|
||||
if body.IsSystem != nil {
|
||||
ext.IsSystem = *body.IsSystem
|
||||
pkg.IsSystem = *body.IsSystem
|
||||
}
|
||||
if body.IsEnabled != nil {
|
||||
ext.IsEnabled = *body.IsEnabled
|
||||
pkg.Enabled = *body.IsEnabled
|
||||
}
|
||||
if body.Manifest != nil {
|
||||
ext.Manifest = *body.Manifest
|
||||
var manifestMap map[string]any
|
||||
if err := json.Unmarshal(*body.Manifest, &manifestMap); err == nil {
|
||||
pkg.Manifest = manifestMap
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
|
||||
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": ext})
|
||||
c.JSON(200, gin.H{"data": pkg})
|
||||
}
|
||||
|
||||
// ServeExtensionAsset serves browser extension JS files.
|
||||
// GET /api/v1/extensions/:id/assets/*path
|
||||
//
|
||||
// For the MVP, scripts are stored inline in manifest._script.
|
||||
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
|
||||
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
|
||||
extID := c.Param("id")
|
||||
// path := c.Param("path") // reserved for future multi-file support
|
||||
pkgID := c.Param("id")
|
||||
|
||||
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
|
||||
if err == sql.ErrNoRows {
|
||||
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 err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
if !ext.IsEnabled {
|
||||
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(ext.Manifest, &manifest); err != nil {
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
c.JSON(500, gin.H{"error": "invalid manifest"})
|
||||
return
|
||||
}
|
||||
@@ -295,7 +297,6 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// _script is a JSON string — unquote it
|
||||
var script string
|
||||
if err := json.Unmarshal(scriptRaw, &script); err != nil {
|
||||
c.JSON(500, gin.H{"error": "invalid script in manifest"})
|
||||
@@ -310,43 +311,39 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
|
||||
// GetExtensionManifest returns the manifest for a specific extension.
|
||||
// GET /api/v1/extensions/:id/manifest
|
||||
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
|
||||
extID := c.Param("id")
|
||||
pkgID := c.Param("id")
|
||||
|
||||
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
|
||||
if err == sql.ErrNoRows {
|
||||
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 err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"data": ext.Manifest})
|
||||
c.JSON(200, gin.H{"data": pkg.Manifest})
|
||||
}
|
||||
|
||||
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
|
||||
// Used by the completion handler to include browser tools in LLM requests.
|
||||
// GET /api/v1/extensions/tools
|
||||
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
|
||||
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 _, ext := range exts {
|
||||
if ext.Tier != "browser" {
|
||||
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(ext.Manifest, &manifest); err != nil {
|
||||
log.Printf("[extensions] failed to parse manifest for %s: %v", ext.ExtID, err)
|
||||
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...)
|
||||
@@ -358,25 +355,39 @@ func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"data": toolSchemas})
|
||||
}
|
||||
|
||||
// AdminUninstallExtension removes an extension.
|
||||
// AdminUninstallExtension removes an extension-type package.
|
||||
// DELETE /api/v1/admin/extensions/:id
|
||||
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
||||
if err == sql.ErrNoRows {
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user