Feat v0.3.8 distribution (#21)
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 19s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m50s
CI/CD / test-sqlite (pull_request) Successful in 3m1s
CI/CD / build-and-deploy (pull_request) Successful in 1m31s

Bundled packages auto-install on first boot for zero-config first run.
Builder image for faster custom builds. Per-environment allowlists for
K8s/Helm (dev=all, test=all, prod=skip).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 22:34:37 +00:00
parent d91ec02dd7
commit 0f2f6a5a39
17 changed files with 944 additions and 11 deletions

View File

@@ -63,6 +63,15 @@ type Config struct {
MTLSAutoActivate bool // auto-activate new users (default true)
MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
// Bundled packages (v0.3.8)
// SKIP_BUNDLED_PACKAGES: set true to disable auto-install of bundled packages on first run.
// BUNDLED_PACKAGES_DIR: directory containing pre-built .pkg archives (default /app/bundled-packages).
// BUNDLED_PACKAGES: comma-separated allowlist of package IDs to install (empty = all).
// e.g. "tasks,schedules,hello-dashboard" installs only those three.
SkipBundledPackages bool
BundledPackagesDir string
BundledPackages string
// OIDC (v0.24.1)
OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard"
OIDCExternalIssuerURL string // OIDC_EXTERNAL_ISSUER_URL
@@ -105,6 +114,10 @@ func Load() *Config {
S3Prefix: getEnv("S3_PREFIX", ""),
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
SkipBundledPackages: getEnvBool("SKIP_BUNDLED_PACKAGES", false),
BundledPackagesDir: getEnv("BUNDLED_PACKAGES_DIR", "/app/bundled-packages"),
BundledPackages: getEnv("BUNDLED_PACKAGES", ""),
LogFormat: getEnv("LOG_FORMAT", "text"),
LogLevel: getEnv("LOG_LEVEL", "info"),

View File

@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS packages (
schema_version INTEGER NOT NULL DEFAULT 0,
package_settings JSONB NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'core'
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
CHECK (source IN ('core', 'builtin', 'extension', 'registry', 'bundled')),
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,6 @@
-- 012_bundled_source.sql — v0.3.8
-- Adds 'bundled' to the packages.source CHECK constraint for auto-installed packages.
ALTER TABLE packages DROP CONSTRAINT IF EXISTS packages_source_check;
ALTER TABLE packages ADD CONSTRAINT packages_source_check
CHECK (source IN ('core', 'builtin', 'extension', 'registry', 'bundled'));

View File

@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS packages (
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')),
CHECK (source IN ('core', 'builtin', 'extension', 'registry', 'bundled')),
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -0,0 +1,39 @@
-- 012_bundled_source.sql — v0.3.8
-- Adds 'bundled' to the packages.source CHECK constraint for auto-installed packages.
-- SQLite doesn't support ALTER CHECK — must recreate the table.
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', 'dormant')),
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', 'bundled')),
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO packages_new SELECT * FROM packages;
DROP TABLE packages;
ALTER TABLE packages_new RENAME TO packages;
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);

View File

@@ -0,0 +1,309 @@
package handlers
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/sandbox"
"switchboard-core/store"
"switchboard-core/triggers"
)
// InstallBundledPackages scans bundledDir for .pkg archives and installs
// any that don't already exist in the database. Called once at startup
// (unless SKIP_BUNDLED_PACKAGES=true).
//
// allowlist is an optional comma-separated list of package IDs to install.
// Empty string means install all. This allows Helm/K8s deployments to
// select a subset: e.g. "tasks,schedules,hello-dashboard".
//
// Design: install-once, skip-if-present. If an admin uninstalls a bundled
// package, it won't be re-installed on the next restart.
func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores store.Stores, runner *sandbox.Runner) {
entries, err := os.ReadDir(bundledDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf("[bundled] No bundled packages directory at %s — skipping", bundledDir)
return
}
log.Printf("[bundled] ⚠️ Cannot read bundled packages: %v", err)
return
}
// Parse allowlist into a set (empty = allow all)
allowed := parseBundleAllowlist(allowlist)
if len(allowed) > 0 {
log.Printf("[bundled] Allowlist: %v", allowlist)
}
var installed, skipped, filtered int
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pkg") {
continue
}
// Check allowlist by filename (strip .pkg extension = package ID)
if len(allowed) > 0 {
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
if !allowed[pkgName] {
filtered++
continue
}
}
pkgPath := filepath.Join(bundledDir, entry.Name())
result, err := installBundledPackage(pkgPath, packagesDir, stores, runner)
if err != nil {
log.Printf("[bundled] ⚠️ Failed to install %s: %v", entry.Name(), err)
continue
}
if result == "skipped" {
skipped++
} else {
installed++
}
}
if installed > 0 || skipped > 0 || filtered > 0 {
msg := fmt.Sprintf("[bundled] Installed %d, skipped %d existing", installed, skipped)
if filtered > 0 {
msg += fmt.Sprintf(", filtered %d by allowlist", filtered)
}
log.Println(msg)
}
}
// parseBundleAllowlist parses a comma-separated string into a set of
// package IDs. Returns nil for empty input (meaning "allow all").
func parseBundleAllowlist(s string) map[string]bool {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
m := make(map[string]bool, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
m[p] = true
}
}
return m
}
// installBundledPackage installs a single .pkg archive if its package ID
// doesn't already exist in the database. Returns "installed" or "skipped".
func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, runner *sandbox.Runner) (string, error) {
ctx := context.Background()
// Open as zip
zr, err := zip.OpenReader(pkgPath)
if err != nil {
return "", fmt.Errorf("invalid zip archive: %w", err)
}
defer zr.Close()
// Find and parse manifest.json
manifest, err := extractManifest(zr)
if err != nil {
return "", err
}
pkgID, _ := manifest["id"].(string)
if pkgID == "" {
return "", fmt.Errorf("manifest missing 'id'")
}
// Skip if already exists (admin uninstalled → stays uninstalled)
existing, _ := stores.Packages.Get(ctx, pkgID)
if existing != nil {
return "skipped", nil
}
title, _ := manifest["title"].(string)
if title == "" {
return "", fmt.Errorf("manifest missing 'title'")
}
pkgType, _ := manifest["type"].(string)
if pkgType == "" {
pkgType = "surface"
}
// Extract static assets to packagesDir/{id}/
if packagesDir != "" {
if err := extractPackageAssets(zr, packagesDir, pkgID); err != nil {
return "", fmt.Errorf("asset extraction: %w", err)
}
}
// Extract manifest fields for DB columns
version, _ := manifest["version"].(string)
if version == "" {
version = "0.0.0"
}
description, _ := manifest["description"].(string)
author, _ := manifest["author"].(string)
tier, _ := manifest["tier"].(string)
if tier == "" {
tier = "browser"
}
// Register in database via Seed (upsert)
if err := stores.Packages.Seed(ctx, pkgID, title, "bundled", manifest); err != nil {
return "", fmt.Errorf("seed failed: %w", err)
}
// Update fields that Seed doesn't set
pkg := &store.PackageRegistration{
Title: title,
Type: pkgType,
Version: version,
Description: description,
Author: author,
Tier: tier,
IsSystem: false,
Enabled: true,
Manifest: manifest,
}
if err := stores.Packages.Update(ctx, pkgID, pkg); err != nil {
log.Printf("[bundled] Failed to update package metadata for %s: %v", pkgID, err)
}
// Create namespaced DB tables declared in the manifest
if tables, ok := ParseDBTables(manifest); ok {
if err := CreateExtTables(ctx, database.DB, database.IsPostgres(), stores, pkgID, tables); err != nil {
log.Printf("[bundled] [ext-db] schema create failed for %s: %v", pkgID, err)
}
}
// Sync permissions — use a minimal gin.Context for the functions that need it
fakeCtx := newBackgroundGinContext()
SyncManifestPermissions(fakeCtx, stores, pkgID, manifest)
// Sync triggers from manifest
SyncManifestTriggers(ctx, stores, triggers.GlobalEngine(), pkgID, manifest)
// Install workflow definition if applicable
if pkgType == "workflow" {
if err := InstallWorkflowFromManifest(fakeCtx, stores, pkgID, manifest); err != nil {
log.Printf("[bundled] workflow install failed for %s: %v", pkgID, err)
}
}
// Handle dormant status for packages with unmet requires
knownCaps := map[string]bool{}
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
var unmetReqs []string
for _, r := range reqs {
req, _ := r.(string)
if req != "" && !knownCaps[req] {
unmetReqs = append(unmetReqs, req)
}
}
if len(unmetReqs) > 0 {
stores.Packages.SetStatus(ctx, pkgID, "dormant")
stores.Packages.SetEnabled(ctx, pkgID, false)
log.Printf("[bundled] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
}
}
// Auto-set default_surface when the first surface is installed
if (pkgType == "surface" || pkgType == "full") {
if raw, err := stores.GlobalConfig.Get(ctx, "default_surface"); err != nil || raw == nil {
dflt := models.JSONMap{"id": pkgID}
if setErr := stores.GlobalConfig.Set(ctx, "default_surface", dflt, ""); setErr != nil {
log.Printf("[bundled] failed to auto-set default_surface to %s: %v", pkgID, setErr)
}
}
}
log.Printf("[bundled] Installed %s (%s) v%s", pkgID, pkgType, version)
return "installed", nil
}
// extractManifest reads and parses manifest.json from a zip archive.
func extractManifest(zr *zip.ReadCloser) (map[string]any, error) {
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, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("invalid manifest.json: %w", err)
}
return manifest, nil
}
}
return nil, fmt.Errorf("archive missing manifest.json")
}
// extractPackageAssets extracts static assets from a zip archive to packagesDir/{pkgID}/.
func extractPackageAssets(zr *zip.ReadCloser, packagesDir, pkgID string) error {
destDir := filepath.Join(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)
// Security: prevent path traversal
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()
}
return nil
}
// newBackgroundGinContext creates a minimal gin.Context backed by a
// background context. Used by startup code that calls functions
// originally designed for HTTP handlers.
func newBackgroundGinContext() *gin.Context {
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
c := &gin.Context{}
c.Request = req
return c
}

View File

@@ -0,0 +1,226 @@
package handlers
import (
"archive/zip"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"switchboard-core/database"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Test helpers ──────────────────────────────
func newTestStores(t *testing.T) store.Stores {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
if database.IsSQLite() {
return sqlite.NewStores(database.TestDB)
}
return postgres.NewStores(database.TestDB)
}
// buildTestPkg creates a minimal .pkg archive in dir with the given manifest.
func buildTestPkg(t *testing.T, dir string, manifest map[string]any) string {
t.Helper()
pkgID, _ := manifest["id"].(string)
if pkgID == "" {
t.Fatal("manifest must have 'id'")
}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
pkgPath := filepath.Join(dir, pkgID+".pkg")
f, err := os.Create(pkgPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zw := zip.NewWriter(f)
w, err := zw.Create("manifest.json")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return pkgPath
}
// ═══════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════
// TestBundledInstall_FreshDB verifies that bundled packages are installed
// into an empty database on first run.
func TestBundledInstall_FreshDB(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
buildTestPkg(t, bundledDir, map[string]any{
"id": "test-surface-a",
"title": "Test Surface A",
"type": "surface",
})
buildTestPkg(t, bundledDir, map[string]any{
"id": "test-surface-b",
"title": "Test Surface B",
"type": "surface",
"route": "/s/test-b",
})
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
// Both packages should be installed and enabled
for _, id := range []string{"test-surface-a", "test-surface-b"} {
pkg, err := stores.Packages.Get(ctx, id)
if err != nil || pkg == nil {
t.Fatalf("package %s not found after install", id)
}
if !pkg.Enabled {
t.Errorf("package %s should be enabled", id)
}
if pkg.Source != "bundled" {
t.Errorf("package %s source = %q, want %q", id, pkg.Source, "bundled")
}
}
}
// TestBundledInstall_SkipsExisting verifies that already-installed packages
// are not re-installed (preserves admin uninstall/reconfigure).
func TestBundledInstall_SkipsExisting(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
// Pre-seed a package with same ID but different title (simulating existing install)
manifest := map[string]any{"id": "pre-existing", "title": "Original Title", "type": "surface"}
if err := stores.Packages.Seed(ctx, "pre-existing", "Original Title", "extension", manifest); err != nil {
t.Fatal(err)
}
// Bundled package has same ID but different title
buildTestPkg(t, bundledDir, map[string]any{
"id": "pre-existing",
"title": "Bundled Title",
"type": "surface",
})
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
// Package should retain original title (not overwritten)
pkg, err := stores.Packages.Get(ctx, "pre-existing")
if err != nil || pkg == nil {
t.Fatal("pre-existing package not found")
}
if pkg.Title != "Original Title" {
t.Errorf("title = %q, want %q (should not overwrite existing)", pkg.Title, "Original Title")
}
}
// TestBundledInstall_MissingDir verifies graceful handling when bundled
// packages directory doesn't exist.
func TestBundledInstall_MissingDir(t *testing.T) {
stores := newTestStores(t)
// Should not panic or error — just log and return
InstallBundledPackages("/nonexistent/path", "", "", stores, nil)
}
// TestBundledInstall_EmptyDir verifies no-op when directory exists but is empty.
func TestBundledInstall_EmptyDir(t *testing.T) {
stores := newTestStores(t)
bundledDir := t.TempDir()
// Should not panic or error
InstallBundledPackages(bundledDir, "", "", stores, nil)
}
// TestBundledInstall_DormantPackage verifies that bundled packages with
// unmet requires are marked dormant.
func TestBundledInstall_DormantPackage(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
buildTestPkg(t, bundledDir, map[string]any{
"id": "dormant-pkg",
"title": "Dormant Package",
"type": "extension",
"requires": []string{"chat"},
})
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
pkg, err := stores.Packages.Get(ctx, "dormant-pkg")
if err != nil || pkg == nil {
t.Fatal("dormant package not found after install")
}
if pkg.Status != "dormant" {
t.Errorf("status = %q, want %q", pkg.Status, "dormant")
}
if pkg.Enabled {
t.Error("dormant package should not be enabled")
}
}
// TestBundledInstall_Allowlist verifies that BUNDLED_PACKAGES allowlist
// filters which packages are installed.
func TestBundledInstall_Allowlist(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
buildTestPkg(t, bundledDir, map[string]any{
"id": "pkg-alpha", "title": "Alpha", "type": "surface",
})
buildTestPkg(t, bundledDir, map[string]any{
"id": "pkg-beta", "title": "Beta", "type": "surface",
})
buildTestPkg(t, bundledDir, map[string]any{
"id": "pkg-gamma", "title": "Gamma", "type": "surface",
})
// Only allow alpha and gamma
InstallBundledPackages(bundledDir, packagesDir, "pkg-alpha,pkg-gamma", stores, nil)
// Alpha and gamma should be installed
for _, id := range []string{"pkg-alpha", "pkg-gamma"} {
pkg, err := stores.Packages.Get(ctx, id)
if err != nil || pkg == nil {
t.Fatalf("package %s should have been installed (in allowlist)", id)
}
}
// Beta should NOT be installed
pkg, _ := stores.Packages.Get(ctx, "pkg-beta")
if pkg != nil {
t.Fatal("pkg-beta should NOT have been installed (not in allowlist)")
}
}

View File

@@ -174,6 +174,17 @@ func main() {
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
}
// ── Bundled Packages (v0.3.8) ───────────────
// Auto-install bundled .pkg archives on first run.
// Skipped if SKIP_BUNDLED_PACKAGES=true or packages already exist.
if !cfg.SkipBundledPackages && stores.Packages != nil {
bundledPkgDir := ""
if cfg.StoragePath != "" {
bundledPkgDir = cfg.StoragePath + "/packages"
}
handlers.InstallBundledPackages(cfg.BundledPackagesDir, bundledPkgDir, cfg.BundledPackages, stores, starlarkRunner)
}
// ── Trigger Engine (v0.2.2) ─────────────────
triggerEngine := triggers.New(stores, starlarkRunner, bus)
if err := triggerEngine.Start(context.Background()); err != nil {