V0.38.2 library packages (#235)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-25 14:09:05 +00:00
committed by xcaliber
parent 6943c91f40
commit 2136535176
22 changed files with 1233 additions and 6 deletions

View File

@@ -0,0 +1,31 @@
-- ==========================================
-- Chat Switchboard — 023 Extension Dependencies
-- ==========================================
-- v0.38.2: Library package dependency tracking.
-- Consumers declare which libraries they depend on.
-- Libraries with active consumers cannot be uninstalled.
-- ==========================================
-- Add 'library' to the packages type constraint.
ALTER TABLE packages DROP CONSTRAINT IF EXISTS packages_type_check;
ALTER TABLE packages ADD CONSTRAINT packages_type_check
CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library'));
CREATE TABLE IF NOT EXISTS ext_dependencies (
consumer_id TEXT NOT NULL,
library_id TEXT NOT NULL,
version_spec TEXT NOT NULL DEFAULT '>=0.0.0',
resolved_ver TEXT NOT NULL DEFAULT '0.0.0',
PRIMARY KEY (consumer_id, library_id),
FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE,
FOREIGN KEY (library_id) REFERENCES packages(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id);
CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id);
COMMENT ON TABLE ext_dependencies IS 'v0.38.2: Package dependency graph — consumers → libraries';
COMMENT ON COLUMN ext_dependencies.consumer_id IS 'Package that depends on the library';
COMMENT ON COLUMN ext_dependencies.library_id IS 'Library package being depended upon';
COMMENT ON COLUMN ext_dependencies.version_spec IS 'Semver version spec declared by consumer (e.g. ">=1.0.0")';
COMMENT ON COLUMN ext_dependencies.resolved_ver IS 'Actual library version at dependency creation time';

View File

@@ -0,0 +1,62 @@
-- ==========================================
-- Chat Switchboard — 023 Extension Dependencies
-- ==========================================
-- v0.38.2: Library package dependency tracking.
-- SQLite version.
--
-- Also adds 'library' to the packages type CHECK constraint.
-- SQLite doesn't support ALTER CONSTRAINT, so we rebuild the table.
-- ==========================================
-- Step 1: Rebuild packages table with updated CHECK constraint.
CREATE TABLE IF NOT EXISTS packages_new (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'surface'
CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library')),
version TEXT NOT NULL DEFAULT '0.0.0',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT 'browser'
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
is_system INTEGER NOT NULL DEFAULT 0,
scope TEXT NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team', 'personal')),
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
manifest TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'pending_review', 'suspended')),
schema_version INTEGER NOT NULL DEFAULT 0,
package_settings TEXT NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'core'
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO packages_new SELECT * FROM packages;
DROP TABLE packages;
ALTER TABLE packages_new RENAME TO packages;
-- Recreate indexes (dropped with old table)
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled);
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status);
-- Step 2: Dependencies table.
CREATE TABLE IF NOT EXISTS ext_dependencies (
consumer_id TEXT NOT NULL,
library_id TEXT NOT NULL,
version_spec TEXT NOT NULL DEFAULT '>=0.0.0',
resolved_ver TEXT NOT NULL DEFAULT '0.0.0',
PRIMARY KEY (consumer_id, library_id),
FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE,
FOREIGN KEY (library_id) REFERENCES packages(id)
);
CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id);
CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id);

View File

@@ -0,0 +1,69 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"chat-switchboard/models"
)
// ── Extension Dependencies (v0.38.2) ──────────────────
// Admin-only read endpoints for dependency graph visibility.
// Dependencies are created/deleted implicitly by InstallPackage/DeletePackage.
// ListDependencies returns all libraries that a package depends on.
// GET /api/v1/admin/packages/:id/dependencies
func (h *PackageHandler) ListDependencies(c *gin.Context) {
id := c.Param("id")
if h.stores.Dependencies == nil {
c.JSON(http.StatusOK, gin.H{"data": []any{}})
return
}
deps, err := h.stores.Dependencies.ListByConsumer(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"})
return
}
if deps == nil {
deps = []models.ExtDependency{}
}
c.JSON(http.StatusOK, gin.H{"data": deps})
}
// ListConsumers returns all packages that depend on a library.
// GET /api/v1/admin/packages/:id/consumers
func (h *PackageHandler) ListConsumers(c *gin.Context) {
id := c.Param("id")
if h.stores.Dependencies == nil {
c.JSON(http.StatusOK, gin.H{"data": []any{}})
return
}
deps, err := h.stores.Dependencies.ListByLibrary(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list consumers"})
return
}
if deps == nil {
deps = []models.ExtDependency{}
}
c.JSON(http.StatusOK, gin.H{"data": deps})
}
// ListAllDependencies returns the full dependency graph.
// GET /api/v1/admin/dependencies
func (h *PackageHandler) ListAllDependencies(c *gin.Context) {
if h.stores.Dependencies == nil {
c.JSON(http.StatusOK, gin.H{"data": []any{}})
return
}
deps, err := h.stores.Dependencies.ListAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"})
return
}
if deps == nil {
deps = []models.ExtDependency{}
}
c.JSON(http.StatusOK, gin.H{"data": deps})
}

View File

@@ -13,8 +13,10 @@ import (
"strings"
"github.com/gin-gonic/gin"
"go.starlark.net/starlark"
"chat-switchboard/database"
"chat-switchboard/models"
"chat-switchboard/sandbox"
"chat-switchboard/store"
)
@@ -29,6 +31,7 @@ type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
runner *sandbox.Runner // v0.38.2: for test-tool execution
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
@@ -44,6 +47,11 @@ func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
h.sandbox = sb
}
// SetRunner attaches the Starlark runner for test-tool execution (v0.38.2).
func (h *PackageHandler) SetRunner(r *sandbox.Runner) {
h.runner = r
}
// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
@@ -126,6 +134,20 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
return
}
// v0.38.2: Libraries with active consumers cannot be uninstalled.
if pkg.Type == "library" && h.stores.Dependencies != nil {
hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id)
if hasConsumers {
c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"})
return
}
}
// v0.38.2: Clean up dependency records when uninstalling a consumer.
if h.stores.Dependencies != nil {
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id)
}
// 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)
@@ -281,8 +303,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
if pkgType == "" {
pkgType = "surface"
}
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"})
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" && pkgType != "library" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'"})
return
}
@@ -323,6 +345,21 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
return
}
case "library":
// v0.38.2: Libraries must declare exports, cannot have tools/pipes/route.
exports, _ := manifest["exports"].([]any)
if len(exports) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages require an 'exports' array"})
return
}
if hasTools || hasPipes {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have tools or pipes"})
return
}
if hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have a route"})
return
}
}
// Check for conflicts with core packages
@@ -470,6 +507,47 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// v0.38.2: Create dependency records from manifest "dependencies" map.
// Libraries must be installed first; consumers declare them.
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
// Clear stale dependency records from a previous install of the same consumer.
if h.stores.Dependencies != nil {
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
}
for libID, vSpec := range deps {
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
if err != nil || lib == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID})
return
}
if lib.Type != "library" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
return
}
if lib.Status != "active" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
return
}
versionSpec, _ := vSpec.(string)
if versionSpec == "" {
versionSpec = ">=0.0.0"
}
if h.stores.Dependencies != nil {
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
ConsumerID: pkgID,
LibraryID: libID,
VersionSpec: versionSpec,
ResolvedVer: lib.Version,
}); err != nil {
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
return
}
}
}
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,
@@ -650,3 +728,78 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"data": enabled})
}
// ── Test Tool (v0.38.2) ─────────────────────────
// POST /admin/packages/:id/test-tool
// Invokes a starlark extension's on_tool_call entry point directly,
// without going through the AI chat loop. Admin-only test harness.
// TestToolRequest is the JSON body for test-tool invocation.
type TestToolRequest struct {
ToolName string `json:"tool_name" binding:"required"`
Arguments map[string]any `json:"arguments"`
}
// TestTool invokes a package's on_tool_call entry point for testing.
func (h *PackageHandler) TestTool(c *gin.Context) {
if h.runner == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"})
return
}
pkgID := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
if pkg.Tier != "starlark" {
c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"})
return
}
if !pkg.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"})
return
}
var req TestToolRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build a call dict identical to what executeExtensionTool sends.
// Build the arguments as an interface{} map for jsonToStarlark.
var args interface{}
if req.Arguments != nil {
argsJSON, _ := json.Marshal(req.Arguments)
_ = json.Unmarshal(argsJSON, &args)
}
callDict := starlark.NewDict(3)
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args))
userID, _ := c.Get("user_id")
rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}
val, output, err := h.runner.CallEntryPoint(
c.Request.Context(), pkg, "on_tool_call",
starlark.Tuple{callDict}, nil, rc,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
"output": output,
})
return
}
// Serialize the Starlark return value to Go types.
result := starlarkValueToGo(val)
c.JSON(http.StatusOK, gin.H{
"result": result,
"output": output,
})
}

View File

@@ -1357,6 +1357,7 @@ func main() {
}
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations
pkgAdm.SetRunner(starlarkRunner) // v0.38.2: test-tool
// Package registry — must be registered before /packages/:id (v0.30.0)
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
@@ -1371,6 +1372,10 @@ func main() {
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0
admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0
admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2
admin.GET("/packages/:id/consumers", pkgAdm.ListConsumers) // v0.38.2
admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
// Package export (v0.30.0)
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)

View File

@@ -0,0 +1,13 @@
package models
// ── Extension Dependencies (v0.38.2) ──────────────────
// ExtDependency records that a consumer package depends on a library package.
// Created at consumer install time from the manifest's "dependencies" map.
// Immutable — update by uninstalling/reinstalling the consumer.
type ExtDependency struct {
ConsumerID string `json:"consumer_id" db:"consumer_id"`
LibraryID string `json:"library_id" db:"library_id"`
VersionSpec string `json:"version_spec" db:"version_spec"`
ResolvedVer string `json:"resolved_ver" db:"resolved_ver"`
}

View File

@@ -0,0 +1,155 @@
// Package sandbox — lib_module.go
//
// v0.38.2: Library loading module for Starlark extensions.
// No permission required — any starlark package can use lib.require().
//
// Starlark API:
// gitea = lib.require("gitea-client") → struct with library's exports
//
// The loaded library runs with its own permission context, not the
// consumer's. Results are cached per ExecPackage invocation.
package sandbox
import (
"context"
"fmt"
"log"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
"chat-switchboard/models"
)
// libContext carries per-invocation state for lib.require() calls.
// Created once per ExecPackage and threaded through buildModules.
type libContext struct {
cache map[string]starlark.Value // library ID → frozen exports struct
loading map[string]bool // cycle detection
}
func newLibContext() *libContext {
return &libContext{
cache: make(map[string]starlark.Value),
loading: make(map[string]bool),
}
}
// BuildLibModule creates the "lib" module.
// consumerPkgID is the package calling lib.require() — used to validate
// the dependency record exists.
func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *RunContext, lc *libContext) *starlarkstruct.Module {
return MakeModule("lib", starlark.StringDict{
"require": starlark.NewBuiltin("lib.require", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var libraryID string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &libraryID); err != nil {
return nil, err
}
// 1. Check cache
if cached, ok := lc.cache[libraryID]; ok {
return cached, nil
}
// 2. Cycle detection
if lc.loading[libraryID] {
return nil, fmt.Errorf("lib.require: circular dependency: %q", libraryID)
}
lc.loading[libraryID] = true
defer func() { delete(lc.loading, libraryID) }()
// 3. Verify dependency is declared
if r.stores.Dependencies == nil {
return nil, fmt.Errorf("lib.require: dependency store not available")
}
deps, err := r.stores.Dependencies.ListByConsumer(ctx, consumerPkgID)
if err != nil {
return nil, fmt.Errorf("lib.require: failed to check dependencies: %w", err)
}
declared := false
for _, dep := range deps {
if dep.LibraryID == libraryID {
declared = true
break
}
}
if !declared {
return nil, fmt.Errorf("lib.require: package %q does not declare dependency on %q", consumerPkgID, libraryID)
}
// 4. Load library's PackageRegistration
libPkg, err := r.stores.Packages.Get(ctx, libraryID)
if err != nil {
return nil, fmt.Errorf("lib.require: library %q not found: %w", libraryID, err)
}
if libPkg.Type != "library" {
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
}
if libPkg.Status != models.PackageStatusActive {
return nil, fmt.Errorf("lib.require: library %q is %s, not active", libraryID, libPkg.Status)
}
if libPkg.Tier != models.ExtTierStarlark {
return nil, fmt.Errorf("lib.require: library %q is tier %s, not starlark", libraryID, libPkg.Tier)
}
// 5. Build library's module set (library's own permissions)
libModules, err := r.buildModulesWithLibCtx(ctx, libraryID, libPkg.Manifest, rc, lc)
if err != nil {
return nil, fmt.Errorf("lib.require: failed to build modules for library %q: %w", libraryID, err)
}
// 6. Load library script from disk
libScript, err := r.loadScript(libPkg)
if err != nil {
return nil, fmt.Errorf("lib.require: %w", err)
}
// 7. Execute with library-scoped loader
libLoader := r.packageLoader(libraryID, libModules)
result, err := r.sandbox.ExecWithLoader(ctx, libraryID+"/script.star", libScript, libModules, libLoader)
if err != nil {
return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err)
}
// 8. Extract exports from manifest
exports := extractExports(libPkg.Manifest)
if len(exports) == 0 {
return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID)
}
members := make(starlark.StringDict, len(exports))
for _, name := range exports {
val, ok := result.Globals[name]
if !ok {
return nil, fmt.Errorf("lib.require: library %q does not export %q", libraryID, name)
}
members[name] = val
}
// 9. Freeze into struct, cache, return
frozen := starlarkstruct.FromStringDict(starlark.String(libraryID), members)
lc.cache[libraryID] = frozen
log.Printf(" 📦 lib.require: loaded %s (%d exports) for %s", libraryID, len(exports), consumerPkgID)
return frozen, nil
}),
})
}
// extractExports reads the "exports" array from a package manifest.
func extractExports(manifest map[string]any) []string {
raw, ok := manifest["exports"].([]any)
if !ok {
return nil
}
exports := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
exports = append(exports, s)
}
}
return exports
}

View File

@@ -13,6 +13,9 @@
// at startup. db.read grants query/view/list_tables; db.write adds
// insert/update/delete. If both are granted, db.write wins (superset).
//
// v0.38.2: Adds lib.load() for library packages. Libraries run with
// their own permission context. Per-invocation lib cache + cycle detection.
//
// The runner is the bridge between the package/permission system
// and the sandboxed Starlark interpreter.
package sandbox
@@ -119,8 +122,11 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
return nil, err
}
// v0.38.2: per-invocation lib context for lib.load() caching + cycle detection
lc := newLibContext()
// Build modules based on granted permissions
modules, err := r.buildModules(ctx, pkg.ID, pkg.Manifest, rc)
modules, err := r.buildModulesWithLibCtx(ctx, pkg.ID, pkg.Manifest, rc, lc)
if err != nil {
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
}
@@ -256,10 +262,17 @@ func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistrat
}
// buildModules assembles the module map based on granted permissions.
// Delegates to buildModulesWithLibCtx with a nil lib context (no lib.load support).
func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) {
return r.buildModulesWithLibCtx(ctx, packageID, manifest, rc, nil)
}
// buildModulesWithLibCtx assembles the module map based on granted permissions.
// The manifest is passed through so modules can read their config
// (e.g., http module reads network_access, provider reads requires_provider).
// The RunContext carries per-invocation state for user-scoped modules.
func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) {
// The libContext enables lib.load() caching and cycle detection (v0.38.2).
func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext, lc *libContext) (map[string]starlark.Value, error) {
if r.stores.ExtPermissions == nil {
return nil, nil
}
@@ -332,5 +345,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
}
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID)
// v0.38.2: lib module — always injected, no permission required.
// Allows any starlark package to load declared library dependencies.
if lc != nil {
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
}
return modules, nil
}

View File

@@ -8820,6 +8820,74 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
# ── Extension Dependencies (v0.38.2) ─────────────────────────────────
/api/v1/admin/packages/{id}/dependencies:
get:
tags: [Admin, Extensions]
summary: List libraries this package depends on
description: "v0.38.2: Returns dependency records where this package is the consumer."
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Dependencies list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ExtDependency'
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/admin/packages/{id}/consumers:
get:
tags: [Admin, Extensions]
summary: List packages that depend on this library
description: "v0.38.2: Returns dependency records where this package is the library."
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Consumers list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ExtDependency'
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/admin/dependencies:
get:
tags: [Admin, Extensions]
summary: List all package dependencies
description: "v0.38.2: Returns the full dependency graph."
security:
- bearerAuth: []
responses:
'200':
description: Full dependency graph
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ExtDependency'
'401':
$ref: '#/components/responses/Unauthorized'
# ── Extension Connections (v0.38.1) ─────────────────────────────────
/api/v1/connections:
get:
@@ -12266,6 +12334,22 @@ components:
updated_at:
type: string
format: date-time
ExtDependency:
type: object
description: "v0.38.2: Package dependency record (consumer → library)"
properties:
consumer_id:
type: string
description: Package ID of the consumer
library_id:
type: string
description: Package ID of the library
version_spec:
type: string
description: Semver version spec declared by consumer
resolved_ver:
type: string
description: Actual library version at dependency creation time
ExtConnection:
type: object
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"

View File

@@ -66,6 +66,7 @@ type Stores struct {
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
Connections ConnectionStore // v0.38.1: Extension connection credentials
Dependencies DependencyStore // v0.38.2: Library package dependency graph
}
// =========================================
@@ -1396,6 +1397,35 @@ type ConnectionStore interface {
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
}
// =========================================
// DEPENDENCY STORE (v0.38.2)
// =========================================
type DependencyStore interface {
// Create records that consumerID depends on libraryID.
Create(ctx context.Context, dep *models.ExtDependency) error
// Delete removes a single dependency record.
Delete(ctx context.Context, consumerID, libraryID string) error
// DeleteAllForConsumer removes all dependency records for a consumer.
// Called when uninstalling a consumer package.
DeleteAllForConsumer(ctx context.Context, consumerID string) error
// ListByConsumer returns all libraries that consumerID depends on.
ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error)
// ListByLibrary returns all consumers that depend on libraryID.
ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error)
// ListAll returns the full dependency graph.
ListAll(ctx context.Context) ([]models.ExtDependency, error)
// HasConsumers returns true if any package depends on libraryID.
// Used for uninstall protection.
HasConsumers(ctx context.Context, libraryID string) (bool, error)
}
// =========================================
// ListOptions provides standard pagination/sort for list queries.

View File

@@ -0,0 +1,95 @@
package postgres
import (
"context"
"database/sql"
"chat-switchboard/models"
)
// ── DependencyStore (v0.38.2) ────────────────────────
type DependencyStore struct{}
func NewDependencyStore() *DependencyStore { return &DependencyStore{} }
const depCols = `consumer_id, library_id, version_spec, resolved_ver`
func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver)
VALUES ($1, $2, $3, $4)
ON CONFLICT (consumer_id, library_id) DO UPDATE
SET version_spec = EXCLUDED.version_spec, resolved_ver = EXCLUDED.resolved_ver`,
dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer,
)
return err
}
func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM ext_dependencies WHERE consumer_id = $1 AND library_id = $2`,
consumerID, libraryID)
return err
}
func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM ext_dependencies WHERE consumer_id = $1`, consumerID)
return err
}
func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = $1 ORDER BY library_id`,
consumerID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDependencies(rows)
}
func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+depCols+` FROM ext_dependencies WHERE library_id = $1 ORDER BY consumer_id`,
libraryID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDependencies(rows)
}
func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDependencies(rows)
}
func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM ext_dependencies WHERE library_id = $1)`,
libraryID).Scan(&exists)
return exists, err
}
// ── Internal ───────────────────────────────────────
func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) {
var result []models.ExtDependency
for rows.Next() {
var d models.ExtDependency
err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer)
if err != nil {
return nil, err
}
result = append(result, d)
}
return result, rows.Err()
}

View File

@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
RateLimits: NewRateLimitStore(),
Export: NewExportStore(),
Connections: NewConnectionStore(),
Dependencies: NewDependencyStore(),
}
}

View File

@@ -0,0 +1,93 @@
package sqlite
import (
"context"
"database/sql"
"chat-switchboard/models"
)
// ── DependencyStore (v0.38.2) ────────────────────────
type DependencyStore struct{}
func NewDependencyStore() *DependencyStore { return &DependencyStore{} }
const depCols = `consumer_id, library_id, version_spec, resolved_ver`
func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error {
_, err := DB.ExecContext(ctx, `
INSERT OR REPLACE INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver)
VALUES (?, ?, ?, ?)`,
dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer,
)
return err
}
func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM ext_dependencies WHERE consumer_id = ? AND library_id = ?`,
consumerID, libraryID)
return err
}
func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM ext_dependencies WHERE consumer_id = ?`, consumerID)
return err
}
func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = ? ORDER BY library_id`,
consumerID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDependencies(rows)
}
func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+depCols+` FROM ext_dependencies WHERE library_id = ? ORDER BY consumer_id`,
libraryID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDependencies(rows)
}
func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDependencies(rows)
}
func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM ext_dependencies WHERE library_id = ?`,
libraryID).Scan(&count)
return count > 0, err
}
// ── Internal ───────────────────────────────────────
func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) {
var result []models.ExtDependency
for rows.Next() {
var d models.ExtDependency
err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer)
if err != nil {
return nil, err
}
result = append(result, d)
}
return result, rows.Err()
}

View File

@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
RateLimits: NewRateLimitStore(),
Export: NewExportStore(),
Connections: NewConnectionStore(),
Dependencies: NewDependencyStore(),
}
}