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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
258
server/handlers/workflow_packages.go
Normal file
258
server/handlers/workflow_packages.go
Normal 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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user