package handlers import ( "encoding/json" "fmt" "regexp" ) // validManifestID matches lowercase alphanumeric slugs with optional hyphens. // Duplicated from packages.go for use in standalone validation. var validManifestID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`) // ManifestInfo holds the parsed and validated fields from a manifest.json. type ManifestInfo struct { ID string Title string Type string // surface, extension, full, workflow, library Version string Description string Author string Tier string SchemaVersion int HasRoute bool HasTools bool HasPipes bool HasHooks bool HasSettings bool HasExports bool Dependencies map[string]any Requires []string Signature string // reserved for future package signing } // ValidateManifest parses a manifest map and validates all required fields, // type constraints, and structural rules. Returns a ManifestInfo or an error // describing the first validation failure. func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { if manifest == nil { return nil, fmt.Errorf("manifest is nil") } info := &ManifestInfo{} // ── Required fields ────────────────────────────────────────── info.ID, _ = manifest["id"].(string) info.Title, _ = manifest["title"].(string) if info.ID == "" || info.Title == "" { return nil, fmt.Errorf("manifest must have 'id' and 'title'") } // ── Package ID slug validation ─────────────────────────────── if !validManifestID.MatchString(info.ID) { return nil, fmt.Errorf("package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)") } // ── Type validation ────────────────────────────────────────── info.Type, _ = manifest["type"].(string) if info.Type == "" { info.Type = "surface" // backward compat } validTypes := map[string]bool{ "surface": true, "extension": true, "full": true, "workflow": true, "library": true, } if !validTypes[info.Type] { return nil, fmt.Errorf("manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'") } // ── Extract optional fields ────────────────────────────────── info.Version, _ = manifest["version"].(string) if info.Version == "" { info.Version = "0.0.0" } info.Description, _ = manifest["description"].(string) info.Author, _ = manifest["author"].(string) info.Tier, _ = manifest["tier"].(string) if info.Tier == "" { info.Tier = "browser" } info.Signature, _ = manifest["signature"].(string) info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil info.HasTools = manifest["tools"] != nil info.HasPipes = manifest["pipes"] != nil info.HasHooks = manifest["hooks"] != nil info.HasSettings = manifest["settings"] != nil info.HasExports = false if deps, ok := manifest["dependencies"].(map[string]any); ok { info.Dependencies = deps } if reqs, ok := manifest["requires"].([]any); ok { for _, r := range reqs { if s, ok := r.(string); ok && s != "" { info.Requires = append(info.Requires, s) } } } info.SchemaVersion = ParseSchemaVersion(manifest) // ── Type-specific constraints ──────────────────────────────── hasExtBehavior := info.HasTools || info.HasPipes || info.HasHooks switch info.Type { case "surface": // route is optional for surfaces case "extension": if !hasExtBehavior { return nil, fmt.Errorf("extension packages require at least one of: tools, pipes, hooks") } if info.HasRoute { return nil, fmt.Errorf("extension packages cannot have a route — use type 'full' for both") } case "full": if !info.HasRoute { return nil, fmt.Errorf("full packages require a route") } if !hasExtBehavior && !info.HasSettings { return nil, fmt.Errorf("full packages require at least one of: tools, pipes, hooks, settings") } case "workflow": if manifest["workflow_definition"] == nil { return nil, fmt.Errorf("workflow packages require a 'workflow_definition' in the manifest") } case "library": exports, _ := manifest["exports"].([]any) if len(exports) == 0 { return nil, fmt.Errorf("library packages require an 'exports' array") } info.HasExports = true if info.HasTools || info.HasPipes { return nil, fmt.Errorf("library packages cannot have tools or pipes") } if info.HasRoute { return nil, fmt.Errorf("library packages cannot have a route") } } return info, nil } // ValidateManifestJSON parses raw JSON bytes into a manifest map and validates. func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) { var manifest map[string]any if err := json.Unmarshal(data, &manifest); err != nil { return nil, nil, fmt.Errorf("invalid manifest.json: %w", err) } info, err := ValidateManifest(manifest) if err != nil { return nil, nil, err } return manifest, info, nil }