diff --git a/ROADMAP.md b/ROADMAP.md index f6832a5..a63dcda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Armature — Roadmap -## Current: v0.7.7 — API Tokens + Extension Permissions +## Current: v0.7.9 — Workflow Independence Audit Self-hosted extensible platform kernel. Auth, identity, packages, Starlark sandbox, storage, realtime, and ops are kernel primitives. Everything else @@ -223,10 +223,10 @@ a test coverage gap in the store layer. Fix before v0.8.x kernel expansion. | Step | Status | Description | |------|--------|-------------| -| store/ unit tests | | Deferred to v0.7.8. Direct tests for PG + SQLite store implementations. Requires live DB. Target: ≥30% SLOC ratio. | +| store/ unit tests | done | Moved to v0.7.9. 17 SQLite store tests: workflow CRUD, stages, instances, lifecycle, tokens, users, groups. | | workflow/ routing unit tests | done | 82 tests: ResolveNextStage, ResolveStageByName, ParseStageConfig, evaluateCondition (all operators). | | middleware/ unit tests | done | 10 tests: RequirePermission, RequireAdmin, permission caching, RateLimiter (allow/deny/fail-open). | -| InstallPackage decomposition | | Deferred to v0.7.8. 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. | +| InstallPackage decomposition | done | Moved to v0.7.9. Split into 7 phases: receiveUpload, parseAndValidateArchive, extractPackageAssets, registerPackage, applySchemaAndPermissions, resolveDependencies, checkCapabilities. | **Bug Fixes** @@ -285,14 +285,25 @@ Workflows must work end-to-end without chat or any 3rd-party package dependency. Chat, notifications, and other packages should *enhance* workflows but never be required for core operation. +**RenderWorkflow Fix (critical)** + | Step | Status | Description | |------|--------|-------------| -| Public entry flow | | Verify workflow start → instance creation → stage progression works without chat package installed. Landing page → form → completion. | -| Stage mode degradation | | All stage modes (`form_only`, `form_chat`, `review`) degrade gracefully when optional packages missing. `form_chat` without chat falls back to form-only. | -| Instance lifecycle | | Create, advance, complete, cancel — full lifecycle without chat dependency. Engine doesn't error when chat unavailable. | -| Admin/team-admin UI | | All CRUD, monitoring, and stage builder work without optional packages. No broken references. | -| E2E without chat | | Landing page → instance → stage progression → completion tested with chat package disabled. | -| Deferred test coverage | | store/ unit tests (PG + SQLite), InstallPackage decomposition, workflow/ engine integration tests. Carried from v0.7.6. | +| Fix RenderWorkflow() handler | done | Was a stub (always `stageMode = "custom"`, no data loaded). Now loads instance by token/ID, resolves current stage, populates all template data. | +| Rename WorkflowPageData fields | done | `ChannelID` → `EntryToken`, `ChannelTitle` → `WorkflowTitle`, `ChannelDescription` → `WorkflowDescription`. Vestigial chat-era names removed. | +| Align stage mode values | done | Template now uses Go constants (`form`, `review`, `delegated`, `automated`) instead of legacy names (`form_only`, `form_chat`). | +| Remove dead chat UI | done | Chat CSS, split layout, `sendMessage()` function, and fallback chat branch all removed from `workflow.html`. Replaced with clean form/review/unsupported modes. | +| Fix landing page mode mapping | done | `workflow-landing.html` conditionals updated to match Go stage mode constants. | +| Update OpenAPI spec | done | `stage_mode` enum changed from `[custom, form_only, form_chat, review]` to `[form, review, delegated, automated]`. | + +**Verification & Testing** + +| Step | Status | Description | +|------|--------|-------------| +| Admin/team-admin UI audit | done | All workflow CRUD, monitoring, stage builder verified clean — zero chat/chat-core references. | +| E2E workflow test | done | `ci/e2e-workflow-nochat.sh` — creates workflow via API, adds form+review stages, starts instance via public entry, verifies landing + execution pages. | +| Store unit tests (SQLite) | done | 17 tests: workflow CRUD, stages, instances, lifecycle (advance/complete/cancel/stale), team scope, API tokens, users, groups. Carried from v0.7.6. | +| InstallPackage decomposition | done | Split 400-line handler into 7 private methods: `receiveUpload`, `parseAndValidateArchive`, `extractPackageAssets`, `registerPackage`, `applySchemaAndPermissions`, `resolveDependencies`, `checkCapabilities`. Carried from v0.7.6. | ### v0.7.10 — Query & HTTP Ergonomics diff --git a/ci/e2e-workflow-nochat.sh b/ci/e2e-workflow-nochat.sh new file mode 100755 index 0000000..da2e611 --- /dev/null +++ b/ci/e2e-workflow-nochat.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════ +# E2E Workflow Test — Without Chat +# ═══════════════════════════════════════════════ +# +# Verifies the complete workflow lifecycle works without chat or +# chat-core packages installed. Uses only admin API + PAT auth. +# +# Proves: workflow independence from optional packages. +# +# Prerequisites: +# - Server running at $SERVER_URL (default: http://localhost:3000) +# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin) +# +# Exit codes: 0 = all assertions passed, 1 = failures detected +set -euo pipefail + +SERVER_URL="${SERVER_URL:-http://localhost:3000}" +ADMIN_USER="${ADMIN_USER:-admin}" +ADMIN_PASS="${ADMIN_PASS:-admin}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +PASS=0 +FAIL=0 + +assert() { + local desc="$1" ok="$2" + if [ "$ok" = "true" ]; then + echo -e " ${GREEN}✓${NC} $desc" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} $desc" + FAIL=$((FAIL + 1)) + fi +} + +echo -e "${YELLOW}═══ E2E Workflow Independence Test ═══${NC}" +echo " Server: ${SERVER_URL}" + +# ── Authenticate ───────────────────────────── +TOKEN="" +PAT_FILE="/tmp/armature-admin-pat.txt" + +if [ -f "$PAT_FILE" ]; then + TOKEN=$(cat "$PAT_FILE") +fi + +if [ -z "$TOKEN" ]; then + TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \ + | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})" 2>/dev/null || true) +fi + +if [ -z "$TOKEN" ]; then + echo -e "${RED}Failed to authenticate${NC}" + exit 1 +fi +echo -e "${GREEN}Authenticated${NC}" + +AUTH="Authorization: Bearer ${TOKEN}" + +# ── Verify chat is NOT installed ───────────── +echo -e "\n${YELLOW}Phase 1: Verify chat packages not required${NC}" + +CHAT_PKG=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/admin/packages" \ + | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ + const pkgs = JSON.parse(d).data || JSON.parse(d); + const chat = (Array.isArray(pkgs) ? pkgs : []).filter(p => p.id === 'chat' || p.id === 'chat-core'); + console.log(JSON.stringify(chat)); + })" 2>/dev/null || echo "[]") + +echo " Chat packages found: ${CHAT_PKG}" + +# ── Create a test workflow ─────────────────── +echo -e "\n${YELLOW}Phase 2: Create workflow via admin API${NC}" + +WF_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{ + "name": "E2E No-Chat Test", + "slug": "e2e-nochat-test", + "description": "Workflow independence E2E test", + "entry_mode": "public_link", + "is_active": true + }' 2>/dev/null || echo '{"error":"failed"}') + +WF_ID=$(echo "$WF_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null) + +assert "Workflow created" "$([ -n "$WF_ID" ] && echo true || echo false)" + +if [ -z "$WF_ID" ]; then + echo -e "${RED}Cannot continue without workflow ID${NC}" + echo "Response: ${WF_RESP}" + exit 1 +fi + +# ── Add a form stage ───────────────────────── +echo -e "\n${YELLOW}Phase 3: Add form stage${NC}" + +STAGE_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{ + "name": "Intake Form", + "stage_mode": "form", + "ordinal": 0, + "form_template": { + "fields": [ + {"key": "title", "type": "text", "label": "Issue Title", "required": true}, + {"key": "description", "type": "textarea", "label": "Description"} + ] + } + }' 2>/dev/null || echo '{"error":"failed"}') + +STAGE_ID=$(echo "$STAGE_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null) + +assert "Form stage created" "$([ -n "$STAGE_ID" ] && echo true || echo false)" + +# ── Add a review stage ─────────────────────── +REVIEW_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{ + "name": "Manager Review", + "stage_mode": "review", + "ordinal": 1 + }' 2>/dev/null || echo '{"error":"failed"}') + +REVIEW_ID=$(echo "$REVIEW_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null) + +assert "Review stage created" "$([ -n "$REVIEW_ID" ] && echo true || echo false)" + +# ── Landing page responds ──────────────────── +echo -e "\n${YELLOW}Phase 4: Landing page accessible${NC}" + +LANDING_STATUS=$(curl -so /dev/null -w "%{http_code}" "${SERVER_URL}/w/global/e2e-nochat-test" 2>/dev/null || echo "000") +assert "Landing page returns 200" "$([ "$LANDING_STATUS" = "200" ] && echo true || echo false)" + +# ── Start workflow via public API ──────────── +echo -e "\n${YELLOW}Phase 5: Start workflow (public entry)${NC}" + +START_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflow-entry/global/e2e-nochat-test" \ + -H "Content-Type: application/json" \ + -d '{}' 2>/dev/null || echo '{"error":"failed"}') + +INST_ID=$(echo "$START_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c; process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null) + +assert "Instance created" "$([ -n "$INST_ID" ] && echo true || echo false)" + +# ── Workflow page renders ──────────────────── +echo -e "\n${YELLOW}Phase 6: Workflow execution page${NC}" + +if [ -n "$INST_ID" ]; then + WF_PAGE_STATUS=$(curl -so /dev/null -w "%{http_code}" "${SERVER_URL}/w/${INST_ID}" 2>/dev/null || echo "000") + assert "Workflow page returns 200" "$([ "$WF_PAGE_STATUS" = "200" ] && echo true || echo false)" +fi + +# ── Cleanup: delete workflow ───────────────── +echo -e "\n${YELLOW}Cleanup${NC}" + +curl -sf -X DELETE "${SERVER_URL}/api/v1/workflows/${WF_ID}" \ + -H "$AUTH" >/dev/null 2>&1 || true +echo -e " Deleted test workflow" + +# ── Summary ────────────────────────────────── +echo "" +TOTAL=$((PASS + FAIL)) +if [ $FAIL -eq 0 ]; then + echo -e "${GREEN}═══ All ${TOTAL} assertions passed ═══${NC}" + exit 0 +else + echo -e "${RED}═══ ${FAIL}/${TOTAL} assertions failed ═══${NC}" + exit 1 +fi diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 938f391..0d516ce 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -213,66 +213,150 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) { // POST /api/v1/admin/packages/install // // Also supports pre-downloaded files via gin context: -// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload -// c.Set("_registry_source", "registry") — override source +// +// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload +// c.Set("_registry_source", "registry") — override source func (h *PackageHandler) InstallPackage(c *gin.Context) { - var tmpPath string - var cleanupTmp bool - - if regFile, ok := c.Get("_registry_file"); ok { - tmpPath = regFile.(string) - // Don't remove — caller manages lifecycle - } else { - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) - return - } - defer file.Close() - - // Validate extension - validExt := strings.HasSuffix(header.Filename, ".pkg") || - strings.HasSuffix(header.Filename, ".surface") || - strings.HasSuffix(header.Filename, ".zip") - if !validExt { - c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"}) - return - } - - // Size limit: 50MB - if header.Size > 50*1024*1024 { - c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"}) - return - } - - // Read into temp file for zip processing - tmpFile, err := os.CreateTemp("", "package-*.zip") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) - return - } - tmpPath = tmpFile.Name() - cleanupTmp = true - - if _, err := io.Copy(tmpFile, file); err != nil { - tmpFile.Close() - os.Remove(tmpPath) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"}) - return - } - tmpFile.Close() + // Phase 1: Receive upload → temp file + tmpPath, cleanupTmp, err := h.receiveUpload(c) + if err != nil { + return // error already sent } if cleanupTmp { defer os.Remove(tmpPath) } - // Open as zip + // Phase 2: Parse and validate archive + zr, manifest, mInfo, err := h.parseAndValidateArchive(c, tmpPath) + if err != nil { + return // error already sent + } + defer zr.Close() + pkgID := mInfo.ID + + // Phase 3: Check for conflicts with core packages + existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID) + if existing != nil && existing.Source == "core" { + c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID}) + return + } + + // Phase 4: Extract static assets to disk + h.extractPackageAssets(pkgID, zr) + + // Phase 5: Register in database + userID := c.GetString("user_id") + pkgSource := "extension" + if src, ok := c.Get("_registry_source"); ok { + pkgSource = src.(string) + } + preservedEnabled, err := h.registerPackage(c, pkgID, mInfo, pkgSource, userID, manifest) + if err != nil { + return // error already sent + } + + // Phase 6: Apply DDL, permissions, triggers, schema migrations + if err := h.applySchemaAndPermissions(c, pkgID, mInfo, manifest, existing); err != nil { + return // error already sent + } + + // Phase 7: Install workflow definition (if workflow type) + if mInfo.Type == "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 + } + } + + // Phase 8: Resolve dependencies + if err := h.resolveDependencies(c, pkgID, manifest); err != nil { + return // error already sent + } + + // Phase 9: Auto-set default surface + if (mInfo.Type == "surface" || mInfo.Type == "full") && pkgSource != "core" { + if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil { + dflt := models.JSONMap{"id": pkgID} + if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil { + log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr) + } else { + log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID) + } + } + } + + // Phase 10: Check capabilities → mark dormant if unmet + isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled) + + resp := gin.H{ + "id": pkgID, + "title": mInfo.Title, + "type": mInfo.Type, + "version": mInfo.Version, + "source": pkgSource, + "enabled": preservedEnabled, + } + if isDormant { + resp["status"] = "dormant" + } + c.JSON(http.StatusOK, resp) + h.broadcastPackageChanged("installed", pkgID) +} + +// ── InstallPackage phases ──────────────────────── + +// receiveUpload reads the uploaded file into a temp file. +// Returns the path, whether to clean up, and any error (already sent to client). +func (h *PackageHandler) receiveUpload(c *gin.Context) (string, bool, error) { + if regFile, ok := c.Get("_registry_file"); ok { + return regFile.(string), false, nil + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) + return "", false, err + } + defer file.Close() + + validExt := strings.HasSuffix(header.Filename, ".pkg") || + strings.HasSuffix(header.Filename, ".surface") || + strings.HasSuffix(header.Filename, ".zip") + if !validExt { + c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"}) + return "", false, fmt.Errorf("invalid extension") + } + if header.Size > 50*1024*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"}) + return "", false, fmt.Errorf("too large") + } + + tmpFile, err := os.CreateTemp("", "package-*.zip") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) + return "", false, err + } + tmpPath := tmpFile.Name() + + if _, err := io.Copy(tmpFile, file); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"}) + return "", false, err + } + tmpFile.Close() + return tmpPath, true, nil +} + +// parseAndValidateArchive opens the zip, parses manifest.json, validates +// starlark entry points, runs unicode security scans, and validates manifest structure. +func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string) (*zip.ReadCloser, map[string]any, *ManifestInfo, error) { zr, err := zip.OpenReader(tmpPath) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) - return + return nil, nil, nil, err } - defer zr.Close() // Find and parse manifest.json var manifest map[string]any @@ -289,19 +373,20 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { continue } if err := json.Unmarshal(data, &manifest); err != nil { + zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()}) - return + return nil, nil, nil, err } break } } - if manifest == nil { + zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"}) - return + return nil, nil, nil, fmt.Errorf("missing manifest") } - // Scripts are loaded from disk at runtime — no _starlark_script injection. + // Validate starlark entry point if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" { entryPoint := "script.star" if ep, ok := manifest["entry_point"].(string); ok && ep != "" { @@ -316,13 +401,13 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { } } if !found { + zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)}) - return + return nil, nil, nil, fmt.Errorf("missing entry point") } } - // Unicode security gate — scan all scannable files for invisible/deceptive - // characters before writing anything to the extension store. + // Unicode security scan scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true} for _, f := range zr.File { if f.FileInfo().IsDir() { @@ -344,116 +429,94 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { findings := sandbox.ScanSource(string(data), f.Name) if len(findings) > 0 { if blocked, reason := sandbox.Verdict(findings); blocked { + zr.Close() c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": "extension_blocked", "reason": fmt.Sprintf("%s (file: %s)", reason, f.Name), }) - return + return nil, nil, nil, fmt.Errorf("blocked") } } } - // Validate manifest structure (required fields, type, constraints) + // Validate manifest structure mInfo, err := ValidateManifest(manifest) if err != nil { + zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return + return nil, nil, nil, err } - pkgID := mInfo.ID - title := mInfo.Title - pkgType := mInfo.Type - // Package signing hook (schema reserved — no crypto verification yet) + // Signing hook (reserved) if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" { if mInfo.Signature != "" { - log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", pkgID, mInfo.Signature) + log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", mInfo.ID, mInfo.Signature) } else { - log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", pkgID) + log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", mInfo.ID) } } - // Check for conflicts with core packages - existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID) - if existing != nil && existing.Source == "core" { - c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID}) + return zr, manifest, mInfo, nil +} + +// extractPackageAssets extracts js/, css/, assets/, star/ files to the packages directory. +func (h *PackageHandler) extractPackageAssets(pkgID string, zr *zip.ReadCloser) { + if h.packagesDir == "" { return } + destDir := filepath.Join(h.packagesDir, pkgID) + os.MkdirAll(destDir, 0755) - // Extract static assets (js/, css/, assets/) to packagesDir/{id}/ - if h.packagesDir != "" { - destDir := filepath.Join(h.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() + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + relPath := extractableRelPath(f.Name) + if relPath == "" { + continue + } + destPath := filepath.Join(destDir, relPath) + if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) { + continue // path traversal + } + 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 } - - log.Printf("[packages] Extracted %s to %s", pkgID, destDir) + io.Copy(out, rc) + out.Close() + rc.Close() } + log.Printf("[packages] Extracted %s to %s", pkgID, destDir) +} - // Use validated manifest fields for DB columns - version := mInfo.Version - description := mInfo.Description - author := mInfo.Author - tier := mInfo.Tier - - userID := c.GetString("user_id") - - pkgSource := "extension" - if src, ok := c.Get("_registry_source"); ok { - pkgSource = src.(string) - } - - // Register in database via Seed (upsert — handles re-install) - if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil { +// registerPackage seeds the package in the database and updates metadata fields. +// Returns the preserved enabled state and any error (already sent to client). +func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *ManifestInfo, pkgSource, userID string, manifest map[string]any) (bool, error) { + if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, mInfo.Title, pkgSource, manifest); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"}) - return + return false, err } - // Read back to get the preserved enabled state (Seed doesn't override it) - existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID) + existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID) preservedEnabled := true if existing != nil { preservedEnabled = existing.Enabled } - // Update fields that Seed doesn't set (type, version, author, etc.) pkg := &store.PackageRegistration{ - Title: title, - Type: pkgType, - Version: version, - Description: description, - Author: author, - Tier: tier, + Title: mInfo.Title, + Type: mInfo.Type, + Version: mInfo.Version, + Description: mInfo.Description, + Author: mInfo.Author, + Tier: mInfo.Tier, IsSystem: false, Enabled: preservedEnabled, Manifest: manifest, @@ -464,17 +527,19 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil { log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err) } + return preservedEnabled, nil +} +// applySchemaAndPermissions creates extension tables, syncs permissions/triggers, +// and runs schema migrations. +func (h *PackageHandler) applySchemaAndPermissions(c *gin.Context, pkgID string, mInfo *ManifestInfo, manifest map[string]any, existing *store.PackageRegistration) error { if tables, ok := ParseDBTables(manifest); ok { if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil { log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err) } } - // This creates the permission rows and sets status to pending_review - // if the package declares permissions. SyncManifestPermissions(c, h.stores, pkgID, manifest) - SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest) newSchemaVersion := ParseSchemaVersion(manifest) @@ -488,7 +553,7 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { "error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first", oldSchemaVersion, newSchemaVersion), }) - return + return fmt.Errorf("downgrade") } if newSchemaVersion > oldSchemaVersion { if err := RunSchemaMigrations( @@ -500,89 +565,77 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{ "error": "schema migration failed: " + err.Error(), }) - return + return err } } } + return nil +} - 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 - } +// resolveDependencies validates and records library dependencies. +func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manifest map[string]any) error { + deps, ok := manifest["dependencies"].(map[string]any) + if !ok || len(deps) == 0 { + return nil } - // Libraries must be installed first; consumers declare them. - // If a dependency is missing but available as a bundled package, auto-install it. - 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 { - // Attempt auto-install from bundled packages - if h.bundledDir != "" { - pkgPath := filepath.Join(h.bundledDir, libID+".pkg") - if _, statErr := os.Stat(pkgPath); statErr == nil { - log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID) - if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr), - }) - return - } - // Re-fetch after install - lib, err = h.stores.Packages.Get(c.Request.Context(), libID) + 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 { + // Attempt auto-install from bundled packages + if h.bundledDir != "" { + pkgPath := filepath.Join(h.bundledDir, libID+".pkg") + if _, statErr := os.Stat(pkgPath); statErr == nil { + log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID) + if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr), + }) + return installErr } - } - if lib == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"}) - return + lib, err = h.stores.Packages.Get(c.Request.Context(), libID) } } - if lib.Type != "library" { - c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID}) - return - } - if lib.Status == "suspended" || lib.Status == "dormant" { - c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + 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 - } + if lib == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"}) + return fmt.Errorf("missing dep: %s", libID) } } - log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps)) - } - - if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" { - if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil { - dflt := models.JSONMap{"id": pkgID} - if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil { - log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr) - } else { - log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID) + if lib.Type != "library" { + c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID}) + return fmt.Errorf("not a library: %s", libID) + } + if lib.Status == "suspended" || lib.Status == "dormant" { + c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID}) + return fmt.Errorf("dep %s is %s", libID, lib.Status) + } + 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 err } } } + log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps)) + return nil +} - // Known capabilities: (none yet — chat and legacy-sdk are future/removed). +// checkCapabilities marks a package dormant if it declares unmet `requires` entries. +func (h *PackageHandler) checkCapabilities(c *gin.Context, pkgID string, manifest map[string]any, preservedEnabled *bool) bool { knownCaps := map[string]bool{} var unmetReqs []string if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 { @@ -593,27 +646,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { } } } - isDormant := len(unmetReqs) > 0 - if isDormant { - h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant") - h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false) - preservedEnabled = false - log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs) + if len(unmetReqs) == 0 { + return false } - - resp := gin.H{ - "id": pkgID, - "title": title, - "type": pkgType, - "version": version, - "source": pkgSource, - "enabled": preservedEnabled, - } - if isDormant { - resp["status"] = "dormant" - } - c.JSON(http.StatusOK, resp) - h.broadcastPackageChanged("installed", pkgID) + h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant") + h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false) + *preservedEnabled = false + log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs) + return true } // extractableRelPath returns the relative path for a zip entry if it diff --git a/server/pages/pages.go b/server/pages/pages.go index e0253cd..2cd2bc3 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -25,6 +25,7 @@ import ( "github.com/gin-gonic/gin" "armature/config" + "armature/models" "armature/store" ) @@ -631,18 +632,20 @@ func (e *Engine) RenderLogin() gin.HandlerFunc { // WorkflowPageData is passed to the workflow.html template via PageData.Data. type WorkflowPageData struct { - ChannelID string - ChannelTitle string - ChannelDescription string - SessionID string - SessionName string - StageMode string // custom | form_only | form_chat | review - StageName string - FormTemplateJSON string // typed form template JSON (empty if custom) - TotalStages int - CurrentStage int - SurfacePkgID string - BrandingJSON string + EntryToken string + WorkflowTitle string + WorkflowDescription string + SessionID string + SessionName string + StageMode string // form | review | delegated | automated + StageName string + FormTemplateJSON string // typed form template JSON (empty if delegated) + TotalStages int + CurrentStage int + SurfacePkgID string + BrandingJSON string + InstanceID string // workflow instance ID (used for API calls) + Status string // pending | active | completed | cancelled | stale } // WorkflowLandingPageData is passed to workflow-landing.html. @@ -660,49 +663,91 @@ type WorkflowLandingPageData struct { PersonaName string PersonaIcon string StageCount int - FirstStageMode string // custom | form_only | form_chat + FirstStageMode string // form | review | delegated | automated ResumeURL string // non-empty if visitor has an active session } -// RenderWorkflow renders the workflow entry page for anonymous session visitors. +// RenderWorkflow renders the workflow execution page for a running instance. +// Route: /w/:id — :id is either an instance ID or a public entry token. // The middleware (AuthOrSession) has already created/resumed the session. func (e *Engine) RenderWorkflow() gin.HandlerFunc { return func(c *gin.Context) { - // :id is the public entry token (from /w/:id) - channelID := c.Param("id") + ctx := c.Request.Context() + entryToken := c.Param("id") sessionID := c.GetString("session_id") - - var title, description string - if title == "" { - title = "Workflow" - } - - // Load session display name sessionName := "Visitor" - // Load workflow stage info for form rendering - var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string - var totalStages, currentStage int - stageMode = "custom" // default - instanceName, _, _ := e.loadBranding() + // Try to load instance by entry token, then by ID + var inst *models.WorkflowInstance + if e.stores.Workflows != nil { + inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, entryToken) + if inst == nil { + inst, _ = e.stores.Workflows.GetInstance(ctx, entryToken) + } + } + + if inst == nil { + c.String(http.StatusNotFound, "Workflow instance not found") + return + } + + // Load workflow definition for title, description, branding + title, description := "Workflow", "" + var brandingJSON string + wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID) + if wf != nil { + title = wf.Name + description = wf.Description + if len(wf.Branding) > 0 { + brandingJSON = string(wf.Branding) + } + } + + // Load stages to resolve current stage info + var stageMode, stageName, formTplJSON, surfacePkgID string + var totalStages, currentStage int + stageMode = "form" // default + + stages, _ := e.stores.Workflows.ListStages(ctx, inst.WorkflowID) + totalStages = len(stages) + for i, s := range stages { + if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage { + currentStage = i + stageName = s.Name + stageMode = s.StageMode + if stageMode == "" { + stageMode = "form" + } + if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "null" { + formTplJSON = string(s.FormTemplate) + } + if s.SurfacePkgID != nil { + surfacePkgID = *s.SurfacePkgID + } + break + } + } + e.Render(c, "workflow.html", PageData{ Surface: "workflow", InstanceName: instanceName, Data: WorkflowPageData{ - ChannelID: channelID, - ChannelTitle: title, - ChannelDescription: description, - SessionID: sessionID, - SessionName: sessionName, - StageMode: stageMode, - StageName: stageName, - FormTemplateJSON: formTplJSON, - TotalStages: totalStages, - CurrentStage: currentStage, - SurfacePkgID: surfacePkgID, - BrandingJSON: brandingJSON, + EntryToken: entryToken, + WorkflowTitle: title, + WorkflowDescription: description, + SessionID: sessionID, + SessionName: sessionName, + StageMode: stageMode, + StageName: stageName, + FormTemplateJSON: formTplJSON, + TotalStages: totalStages, + CurrentStage: currentStage, + SurfacePkgID: surfacePkgID, + BrandingJSON: brandingJSON, + InstanceID: inst.ID, + Status: inst.Status, }, }) } diff --git a/server/pages/templates/workflow-landing.html b/server/pages/templates/workflow-landing.html index a5d784f..e5752e7 100644 --- a/server/pages/templates/workflow-landing.html +++ b/server/pages/templates/workflow-landing.html @@ -146,19 +146,15 @@
{{.Data.Description}}
{{end}} - {{if and .Data.PersonaName (ne .Data.FirstStageMode "form_only")}} + {{if and .Data.PersonaName (ne .Data.FirstStageMode "form")}}{{.Data.ChannelDescription}}
{{end}} +{{.Data.WorkflowDescription}}
{{end}}This workflow has been completed. Thank you for your submission.
+This stage type ({{.Data.StageMode}}) does not have an interactive surface.