Changeset 0.30.2 (#201)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-18 15:10:11 +00:00
committed by xcaliber
parent 8fee53e440
commit 7b0b6eb061
22 changed files with 1217 additions and 47 deletions

View File

@@ -365,18 +365,28 @@ func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (pr
// wrapped with a different password), the vault and personal providers are
// destroyed so initVault fires cleanly on next login.
//
// Used by BootstrapAdmin and SeedUsers where the password is known at
// startup but the UEK cache is not available.
// When uekCache is non-nil and the vault unlocks successfully, the UEK is
// cached so that BYOK operations work immediately without requiring a fresh
// login. This is important for bootstrapped/seeded users whose browser
// sessions survive server restarts.
//
// Used by BootstrapAdmin and SeedUsers where the password is known at startup.
// v0.29.0: accepts stores instead of using database.DB directly.
func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string) {
// v0.30.2: accepts optional uekCache to pre-warm vault on restart.
func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string, uekCache ...*crypto.UEKCache) {
vaultSet, encryptedUEK, salt, nonce, err := stores.Users.GetVaultKeys(ctx, userID)
if err != nil || !vaultSet {
return // no vault to probe
}
pdk := crypto.DeriveKeyFromPassword(password, salt)
if _, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk); err == nil {
return // vault seal matches current password — all good
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
if err == nil {
// Vault seal matches current password — cache UEK if cache provided
if len(uekCache) > 0 && uekCache[0] != nil {
uekCache[0].Store(userID, uek)
}
return
}
// Stale seal: password has actually changed since the vault was sealed
@@ -465,7 +475,9 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo
}
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
// When uekCache is provided, the admin's vault is pre-warmed so BYOK
// operations work immediately without requiring a fresh login.
func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
@@ -492,7 +504,11 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword)
var cache *crypto.UEKCache
if len(uekCache) > 0 {
cache = uekCache[0]
}
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache)
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return
}
@@ -524,7 +540,7 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user".
// Upsert: existing users get their password and role refreshed on every restart.
// Gated to non-production environments.
func SeedUsers(cfg *config.Config, s store.Stores) {
func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
if cfg.SeedUsers == "" {
log.Printf(" SEED_USERS not set, skipping")
return
@@ -582,7 +598,11 @@ func SeedUsers(cfg *config.Config, s store.Stores) {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
ProbeAndRepairVault(ctx, s, existing.ID, password)
var cache *crypto.UEKCache
if len(uekCache) > 0 {
cache = uekCache[0]
}
ProbeAndRepairVault(ctx, s, existing.ID, password, cache)
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
continue
}

View File

@@ -260,8 +260,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
if pkgType == "" {
pkgType = "surface"
}
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', or 'full'"})
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'"})
return
}
@@ -293,6 +293,11 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks"})
return
}
case "workflow":
if manifest["workflow_definition"] == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
return
}
}
// Check for conflicts with core packages
@@ -431,6 +436,15 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// v0.30.2: Install workflow definition from package manifest.
if pkgType == "workflow" {
if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,

View File

@@ -0,0 +1,258 @@
package handlers
// workflow_packages.go — v0.30.2 CS1
//
// Handles workflow-specific package operations:
// - ExportWorkflowPackage: bundles a workflow definition + stages into a .pkg
// - InstallWorkflowFromManifest: creates/updates a workflow from a .pkg manifest
import (
"archive/zip"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// WorkflowPackageHandler handles workflow package export and install.
type WorkflowPackageHandler struct {
stores store.Stores
}
// NewWorkflowPackageHandler creates a new workflow package handler.
func NewWorkflowPackageHandler(s store.Stores) *WorkflowPackageHandler {
return &WorkflowPackageHandler{stores: s}
}
// ExportWorkflowPackage exports a workflow definition as a downloadable .pkg.
// GET /api/v1/admin/workflows/:id/export
func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
ctx := c.Request.Context()
wfID := c.Param("id")
wf, err := h.stores.Workflows.GetByID(ctx, wfID)
if err != nil || wf == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
return
}
stages, err := h.stores.Workflows.ListStages(ctx, wfID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
return
}
// Build stage definitions for the manifest
stageDefs := make([]map[string]any, 0, len(stages))
for _, s := range stages {
sd := map[string]any{
"name": s.Name,
"ordinal": s.Ordinal,
"stage_mode": s.StageMode,
"history_mode": s.HistoryMode,
"auto_transition": s.AutoTransition,
}
if s.PersonaID != nil {
sd["persona_id"] = *s.PersonaID
}
if s.AssignmentTeamID != nil {
sd["assignment_team_id"] = *s.AssignmentTeamID
}
if s.SurfacePkgID != nil {
sd["surface_pkg_id"] = *s.SurfacePkgID
}
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
var ft any
if json.Unmarshal(s.FormTemplate, &ft) == nil {
sd["form_template"] = ft
}
}
if len(s.TransitionRules) > 0 && string(s.TransitionRules) != "{}" {
var tr any
if json.Unmarshal(s.TransitionRules, &tr) == nil {
sd["transition_rules"] = tr
}
}
stageDefs = append(stageDefs, sd)
}
// Build the manifest
manifest := map[string]any{
"id": wf.Slug,
"title": wf.Name,
"type": "workflow",
"workflow_definition": map[string]any{
"name": wf.Name,
"slug": wf.Slug,
"entry_mode": wf.EntryMode,
"stages": stageDefs,
},
}
if wf.Description != "" {
manifest["description"] = wf.Description
}
// Include branding if set
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
var branding any
if json.Unmarshal(wf.Branding, &branding) == nil {
wfDef := manifest["workflow_definition"].(map[string]any)
wfDef["branding"] = branding
}
}
// Set response headers
filename := fmt.Sprintf("%s.pkg", wf.Slug)
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Create zip writer directly to response
zw := zip.NewWriter(c.Writer)
defer zw.Close()
// Write manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"})
return
}
mf, err := zw.Create("manifest.json")
if err != nil {
return
}
mf.Write(manifestJSON)
}
// InstallWorkflowFromManifest creates or updates a workflow from a package manifest.
// Called from InstallPackage when type="workflow".
func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID string, manifest map[string]any) error {
wfDefRaw, ok := manifest["workflow_definition"]
if !ok {
return fmt.Errorf("missing workflow_definition in manifest")
}
// Re-marshal and parse for type safety
wfDefJSON, err := json.Marshal(wfDefRaw)
if err != nil {
return fmt.Errorf("invalid workflow_definition: %w", err)
}
var wfDef struct {
Name string `json:"name"`
Slug string `json:"slug"`
EntryMode string `json:"entry_mode"`
Branding json.RawMessage `json:"branding"`
Stages []workflowPkgStage `json:"stages"`
}
if err := json.Unmarshal(wfDefJSON, &wfDef); err != nil {
return fmt.Errorf("invalid workflow_definition: %w", err)
}
if wfDef.Name == "" || wfDef.Slug == "" {
return fmt.Errorf("workflow_definition requires name and slug")
}
if wfDef.EntryMode == "" {
wfDef.EntryMode = "public_link"
}
userID := ctx.GetString("user_id")
reqCtx := ctx.Request.Context()
// Check if workflow already exists (by slug, global scope)
existing, _ := stores.Workflows.GetBySlug(reqCtx, nil, wfDef.Slug)
var workflowID string
if existing != nil {
// Update existing workflow
workflowID = existing.ID
patch := models.WorkflowPatch{
Name: &wfDef.Name,
EntryMode: &wfDef.EntryMode,
}
if len(wfDef.Branding) > 0 {
b := json.RawMessage(wfDef.Branding)
patch.Branding = &b
}
if err := stores.Workflows.Update(reqCtx, workflowID, patch); err != nil {
return fmt.Errorf("failed to update workflow: %w", err)
}
// Delete existing stages (will be replaced)
existingStages, _ := stores.Workflows.ListStages(reqCtx, workflowID)
for _, s := range existingStages {
stores.Workflows.DeleteStage(reqCtx, s.ID)
}
} else {
// Create new workflow
wf := &models.Workflow{
Name: wfDef.Name,
Slug: wfDef.Slug,
EntryMode: wfDef.EntryMode,
CreatedBy: userID,
}
if len(wfDef.Branding) > 0 {
wf.Branding = json.RawMessage(wfDef.Branding)
}
if err := stores.Workflows.Create(reqCtx, wf); err != nil {
return fmt.Errorf("failed to create workflow: %w", err)
}
workflowID = wf.ID
}
// Create stages from definition
for _, s := range wfDef.Stages {
st := &models.WorkflowStage{
WorkflowID: workflowID,
Ordinal: s.Ordinal,
Name: s.Name,
StageMode: s.StageMode,
HistoryMode: s.HistoryMode,
AutoTransition: s.AutoTransition,
PersonaID: s.PersonaID,
AssignmentTeamID: s.AssignmentTeamID,
SurfacePkgID: s.SurfacePkgID,
}
if st.StageMode == "" {
st.StageMode = models.StageModeChatOnly
}
if st.HistoryMode == "" {
st.HistoryMode = "full"
}
if s.FormTemplate != nil {
st.FormTemplate, _ = json.Marshal(s.FormTemplate)
}
if s.TransitionRules != nil {
st.TransitionRules, _ = json.Marshal(s.TransitionRules)
}
if err := stores.Workflows.CreateStage(reqCtx, st); err != nil {
log.Printf("[workflow-pkg] failed to create stage %q: %v", s.Name, err)
}
}
// Store workflow_id in package_settings for reference
settingsJSON, _ := json.Marshal(map[string]string{"workflow_id": workflowID})
stores.Packages.SetPackageSettings(reqCtx, pkgID, json.RawMessage(settingsJSON))
log.Printf("[workflow-pkg] installed workflow %q (id=%s) from package %s", wfDef.Name, workflowID, pkgID)
return nil
}
// workflowPkgStage is the stage definition within a workflow package manifest.
type workflowPkgStage struct {
Name string `json:"name"`
Ordinal int `json:"ordinal"`
StageMode string `json:"stage_mode"`
HistoryMode string `json:"history_mode"`
AutoTransition bool `json:"auto_transition"`
PersonaID *string `json:"persona_id,omitempty"`
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
FormTemplate any `json:"form_template,omitempty"`
TransitionRules any `json:"transition_rules,omitempty"`
}

View File

@@ -192,7 +192,7 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
st.StageMode = models.StageModeChatOnly
}
if !models.ValidStageModes[st.StageMode] {
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"})
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"})
return
}
if st.Ordinal == 0 {
@@ -221,7 +221,7 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
return
}
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"})
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"})
return
}
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {