package handlers import ( "encoding/json" "fmt" "regexp" "strings" ) // 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, test-runner Version string Description string Author string Tier string SchemaVersion int HasRoute bool HasSurfaces bool HasTools bool HasPipes bool HasHooks bool HasSettings bool HasExports bool Dependencies map[string]any Requires []string Signature string // reserved for future package signing UserPermissions []string // user-facing permissions declared by the extension GatePermission string // if set, checks this user permission before calling on_request } // 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, "test-runner": true, } if !validTypes[info.Type] { return nil, fmt.Errorf("manifest type must be 'surface', 'extension', 'full', 'workflow', 'library', or 'test-runner'") } // ── 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) // ── Surfaces validation ───────────────────────────────────── if rawSurfaces, ok := manifest["surfaces"].([]any); ok { if len(rawSurfaces) == 0 { return nil, fmt.Errorf("surfaces array cannot be empty") } seen := map[string]bool{} for i, raw := range rawSurfaces { s, ok := raw.(map[string]any) if !ok { return nil, fmt.Errorf("surfaces[%d] must be an object", i) } path, _ := s["path"].(string) if path == "" { return nil, fmt.Errorf("surfaces[%d] requires a path", i) } if !strings.HasPrefix(path, "/") { return nil, fmt.Errorf("surfaces[%d] path must start with /", i) } if seen[path] { return nil, fmt.Errorf("duplicate surface path: %s", path) } seen[path] = true if access, ok := s["access"].(string); ok { if !validAccessLevels(access) { return nil, fmt.Errorf("surfaces[%d] invalid access: %s", i, access) } } } info.HasSurfaces = true info.HasRoute = true } else if manifest["surfaces"] != nil { // surfaces key present but not an array return nil, fmt.Errorf("surfaces must be an array") } else { // No surfaces declared — synthesize from legacy fields. // This ensures every routable package always has a surfaces array. if info.Type == "surface" || info.Type == "full" { auth, _ := manifest["auth"].(string) if auth == "" { auth = "authenticated" } layout, _ := manifest["layout"].(string) if layout == "" { layout = "single" } manifest["surfaces"] = []any{ map[string]any{ "path": "/", "access": auth, "layout": layout, "title": info.Title, }, } info.HasSurfaces = true } info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil || info.HasSurfaces } 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) } } } // Extension-declared user permissions if ups, ok := manifest["user_permissions"].([]any); ok { for _, v := range ups { if s, ok := v.(string); ok && s != "" { info.UserPermissions = append(info.UserPermissions, s) } } } info.GatePermission, _ = manifest["gate_permission"].(string) 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") } case "test-runner": // Test runners are surface-like packages discovered by type. // They are not shown in navigation (extensionNavItems filters for surface/full). // They may have a route but it's optional — the registry surface provides access. } return info, nil } // validAccessLevels checks whether an access string is a recognized value. func validAccessLevels(access string) bool { switch { case access == "public", access == "authenticated", access == "admin": return true case strings.HasPrefix(access, "group:"): return strings.TrimPrefix(access, "group:") != "" default: return false } } // 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 }